From 4796a7c9193ae29fcebf022125b3f9e9da91f4e3 Mon Sep 17 00:00:00 2001 From: Laura Date: Thu, 4 Jan 2024 16:54:57 +0100 Subject: [PATCH 01/22] WIP initial implementation, User crud, half Article crud --- examples/realworld-app/HELP.md | 23 ++ examples/realworld-app/README.md | 2 + examples/realworld-app/build.gradle | 24 ++ .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43462 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + examples/realworld-app/gradlew | 249 +++++++++++++++++ examples/realworld-app/gradlew.bat | 92 +++++++ .../realworld-app/rw-database/build.gradle | 30 +++ .../java/realworld/db/ArticleService.java | 181 +++++++++++++ .../main/java/realworld/db/ElasticClient.java | 79 ++++++ .../main/java/realworld/db/UserService.java | 253 ++++++++++++++++++ .../realworld/entity/article/Article.java | 12 + .../realworld/entity/article/ArticleDAO.java | 30 +++ .../entity/article/ArticleEntity.java | 25 ++ .../realworld/entity/article/Articles.java | 5 + .../ResourceAlreadyExistsException.java | 8 + .../exception/ResourceNotFoundException.java | 8 + .../java/realworld/entity/user/Author.java | 23 ++ .../java/realworld/entity/user/Profile.java | 19 ++ .../main/java/realworld/entity/user/User.java | 22 ++ .../java/realworld/entity/user/UserDAO.java | 22 ++ .../realworld/entity/user/UserEntity.java | 14 + examples/realworld-app/rw-rest/build.gradle | 25 ++ .../realworld/rest/ArticleController.java | 84 ++++++ .../realworld/rest/ProfileController.java | 53 ++++ .../java/realworld/rest/UserController.java | 81 ++++++ .../java/realworld/rest/error/RestError.java | 13 + .../rest/error/RestExceptionHandler.java | 43 +++ examples/realworld-app/rw-server/build.gradle | 26 ++ .../main/java/realworld/SpringBootApp.java | 15 ++ .../realworld/config/DefaultProperties.java | 19 ++ .../src/main/resources/application.properties | 10 + examples/realworld-app/settings.gradle | 5 + 33 files changed, 1502 insertions(+) create mode 100644 examples/realworld-app/HELP.md create mode 100644 examples/realworld-app/README.md create mode 100644 examples/realworld-app/build.gradle create mode 100644 examples/realworld-app/gradle/wrapper/gradle-wrapper.jar create mode 100644 examples/realworld-app/gradle/wrapper/gradle-wrapper.properties create mode 100755 examples/realworld-app/gradlew create mode 100644 examples/realworld-app/gradlew.bat create mode 100644 examples/realworld-app/rw-database/build.gradle create mode 100644 examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java create mode 100644 examples/realworld-app/rw-database/src/main/java/realworld/db/ElasticClient.java create mode 100644 examples/realworld-app/rw-database/src/main/java/realworld/db/UserService.java create mode 100644 examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Article.java create mode 100644 examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleDAO.java create mode 100644 examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleEntity.java create mode 100644 examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Articles.java create mode 100644 examples/realworld-app/rw-database/src/main/java/realworld/entity/exception/ResourceAlreadyExistsException.java create mode 100644 examples/realworld-app/rw-database/src/main/java/realworld/entity/exception/ResourceNotFoundException.java create mode 100644 examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Author.java create mode 100644 examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Profile.java create mode 100644 examples/realworld-app/rw-database/src/main/java/realworld/entity/user/User.java create mode 100644 examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserDAO.java create mode 100644 examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserEntity.java create mode 100644 examples/realworld-app/rw-rest/build.gradle create mode 100644 examples/realworld-app/rw-rest/src/main/java/realworld/rest/ArticleController.java create mode 100644 examples/realworld-app/rw-rest/src/main/java/realworld/rest/ProfileController.java create mode 100644 examples/realworld-app/rw-rest/src/main/java/realworld/rest/UserController.java create mode 100644 examples/realworld-app/rw-rest/src/main/java/realworld/rest/error/RestError.java create mode 100644 examples/realworld-app/rw-rest/src/main/java/realworld/rest/error/RestExceptionHandler.java create mode 100644 examples/realworld-app/rw-server/build.gradle create mode 100644 examples/realworld-app/rw-server/src/main/java/realworld/SpringBootApp.java create mode 100644 examples/realworld-app/rw-server/src/main/java/realworld/config/DefaultProperties.java create mode 100644 examples/realworld-app/rw-server/src/main/resources/application.properties create mode 100644 examples/realworld-app/settings.gradle diff --git a/examples/realworld-app/HELP.md b/examples/realworld-app/HELP.md new file mode 100644 index 000000000..91d4f4511 --- /dev/null +++ b/examples/realworld-app/HELP.md @@ -0,0 +1,23 @@ +# Getting Started + +### Reference Documentation +For further reference, please consider the following sections: + +* [Official Gradle documentation](https://docs.gradle.org) +* [Spring Boot Gradle Plugin Reference Guide](https://docs.spring.io/spring-boot/docs/3.2.1/gradle-plugin/reference/html/) +* [Create an OCI image](https://docs.spring.io/spring-boot/docs/3.2.1/gradle-plugin/reference/html/#build-image) +* [Spring Security](https://docs.spring.io/spring-boot/docs/3.2.1/reference/htmlsingle/index.html#web.security) +* [Spring Data Elasticsearch (Access+Driver)](https://docs.spring.io/spring-boot/docs/3.2.1/reference/htmlsingle/index.html#data.nosql.elasticsearch) + +### Guides +The following guides illustrate how to use some features concretely: + +* [Securing a Web Application](https://spring.io/guides/gs/securing-web/) +* [Spring Boot and OAuth2](https://spring.io/guides/tutorials/spring-boot-oauth2/) +* [Authenticating a User with LDAP](https://spring.io/guides/gs/authenticating-ldap/) + +### Additional Links +These additional references should also help you: + +* [Gradle Build Scans – insights for your project's build](https://scans.gradle.com#gradle) + diff --git a/examples/realworld-app/README.md b/examples/realworld-app/README.md new file mode 100644 index 000000000..d9d5c6c8d --- /dev/null +++ b/examples/realworld-app/README.md @@ -0,0 +1,2 @@ +# realworldapp-test +first test for a realworldapp backend using springboot and elasticsearch diff --git a/examples/realworld-app/build.gradle b/examples/realworld-app/build.gradle new file mode 100644 index 000000000..a96055951 --- /dev/null +++ b/examples/realworld-app/build.gradle @@ -0,0 +1,24 @@ +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-parent:3.2.0' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/examples/realworld-app/gradle/wrapper/gradle-wrapper.jar b/examples/realworld-app/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..d64cd4917707c1f8861d8cb53dd15194d4248596 GIT binary patch literal 43462 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 literal 0 HcmV?d00001 diff --git a/examples/realworld-app/gradle/wrapper/gradle-wrapper.properties b/examples/realworld-app/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..1af9e0930 --- /dev/null +++ b/examples/realworld-app/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/examples/realworld-app/gradlew b/examples/realworld-app/gradlew new file mode 100755 index 000000000..1aa94a426 --- /dev/null +++ b/examples/realworld-app/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/examples/realworld-app/gradlew.bat b/examples/realworld-app/gradlew.bat new file mode 100644 index 000000000..93e3f59f1 --- /dev/null +++ b/examples/realworld-app/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/examples/realworld-app/rw-database/build.gradle b/examples/realworld-app/rw-database/build.gradle new file mode 100644 index 000000000..55474fa92 --- /dev/null +++ b/examples/realworld-app/rw-database/build.gradle @@ -0,0 +1,30 @@ +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() +} +//TODO uniform imports +dependencies { + implementation 'org.springframework.boot:spring-boot-starter:3.2.0' + implementation("co.elastic.clients:elasticsearch-java:8.11.2") + implementation("com.fasterxml.jackson.core:jackson-databind:2.12.3") + implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.16.0") + implementation ("io.jsonwebtoken:jjwt:0.9.1") + implementation("javax.xml.bind:jaxb-api:2.3.1") + implementation 'com.github.slugify:slugify:3.0.6' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java b/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java new file mode 100644 index 000000000..fbbf5fdd1 --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java @@ -0,0 +1,181 @@ +package realworld.db; + +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.elasticsearch._types.query_dsl.MatchQuery.Builder; +import co.elastic.clients.elasticsearch._types.query_dsl.Query; +import co.elastic.clients.elasticsearch.core.IndexRequest; +import co.elastic.clients.elasticsearch.core.SearchResponse; +import co.elastic.clients.elasticsearch.core.search.Hit; +import com.github.slugify.Slugify; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import realworld.entity.article.Article; +import realworld.entity.article.ArticleDAO; +import realworld.entity.article.ArticleEntity; +import realworld.entity.article.Articles; +import realworld.entity.exception.ResourceAlreadyExistsException; +import realworld.entity.user.Author; +import realworld.entity.user.UserDAO; + +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +@Service +public class ArticleService { + + private ElasticsearchClient esClient; + private UserService userService; + + @Autowired + public ArticleService(ElasticsearchClient esClient, UserService userService) { + this.esClient = esClient; + this.userService = userService; + } + + public ArticleEntity newArticle(Article article, String auth) throws IOException { + + // checking if slug would be unique + String slug = Slugify.builder().build().slugify(article.title()); + if (!singleArticleBySlug(slug).hits().hits().isEmpty()) { + throw new ResourceAlreadyExistsException("Article slug already exists, please change the title"); + } + + // getting the author + UserDAO ue = userService.getUserFromToken(auth); + Author author = new Author(ue,false); + Instant now = Instant.now(); + + ArticleEntity articleEntity = new ArticleEntity(article,slug,now,now,author); + + IndexRequest articleReq = IndexRequest.of((id -> id + .index("articles") + .document(articleEntity))); + + esClient.index(articleReq); + + return articleEntity; + } + + public SearchResponse singleArticleBySlug(String slug) throws IOException { + + // using term query to match exactly the slug and check if it already exists + SearchResponse getArticle = esClient.search(ss -> ss + .index("articles") + .query(q -> q + .term(t -> t + .field("slug.keyword") + .value(slug)) + ) + , ArticleEntity.class); + + return getArticle; + } + + public ArticleEntity getArticleBySlug(String slug) throws IOException { + SearchResponse articleSearch = singleArticleBySlug(slug); + if (articleSearch.hits().hits().isEmpty()) { + throw new RuntimeException("Article not found"); + } + return articleSearch.hits().hits().getFirst().source(); + } + + public ArticleEntity favoriteArticle(String slug, String auth) throws IOException { + UserDAO user = userService.getUserFromToken(auth); + + SearchResponse articleSearch = singleArticleBySlug(slug); + if (articleSearch.hits().hits().isEmpty()) { + throw new RuntimeException("Article not found"); + } + ArticleEntity article = articleSearch.hits().hits().getFirst().source(); + article.favoritedBy().add(user.username()); + article = new ArticleEntity(article.slug(), article.title(), article.description(), article.body(), article.tagList(), article.createdAt(), article.updatedAt(), true, article.favoritesCount()+1, article.favoritedBy(), article.author()); + //TODO update in db + return article; + } + + public ArticleEntity unfavoriteArticle(String slug, String auth) throws IOException { + UserDAO user = userService.getUserFromToken(auth); + + SearchResponse articleSearch = singleArticleBySlug(slug); + if (articleSearch.hits().hits().isEmpty()) { + throw new RuntimeException("Article not found"); + } + ArticleEntity article = articleSearch.hits().hits().getFirst().source(); + // TODO check if it's favorited before + article.favoritedBy().remove(user.username()); + int favoriteCount = article.favoritesCount()-1; + boolean favorited = article.favorited(); + if(favoriteCount==0){ + favorited=false; + } + // TODO update in db + article = new ArticleEntity(article.slug(), article.title(), article.description(), article.body(), article.tagList(), article.createdAt(), article.updatedAt(), favorited, favoriteCount, article.favoritedBy(), article.author()); + return article; + } + + public Articles getArticles(String tag, String author, String favorited, Integer limit, Integer offset) throws IOException { + + List match = new ArrayList<>(); + // since all the parameters for this query are optional, the query must be build conditionally + if (!isNullOrBlank(tag)) { + match.add(new Builder() + .field("tagList") + .query(tag).build()._toQuery()); + } + if (!isNullOrBlank(author)) { + match.add(new Builder() + .field("author") + .query(author).build()._toQuery()); + } + if (!isNullOrBlank(favorited)) { + match.add(new Builder() + .field("favoritedBy") + .query(favorited).build()._toQuery()); + } + + Query query = new Query.Builder().bool(b -> b.should(match)).build(); + + SearchResponse getArticle = esClient.search(ss -> ss + .index("articles") + .size(limit) + .from(offset) + .query(query), ArticleEntity.class); + + return new Articles(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(ArticleDAO::new) + .collect(Collectors.toList()), getArticle.hits().hits().size()); + } + + // TODO remove + public List allArticles() throws IOException { + SearchResponse getArticle = esClient.search(ss -> ss + .index("articles") + .query(q -> q + .matchAll(m -> m) + ) + , ArticleEntity.class); + + return getArticle.hits().hits() + .stream() + .map(Hit::source) + .collect(Collectors.toList()); + } + //TODO common utility class + private boolean isNullOrBlank(String s) { + return Objects.isNull(s) || s.isBlank(); + } + +} 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..2f1846de8 --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/db/ElasticClient.java @@ -0,0 +1,79 @@ +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; + +@Configuration +public class ElasticClient { + + @Value("${elasticsearch.server.url}") + private String serverUrl; + + @Value("${elasticsearch.api.key}") + private String apiKey; + + @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(); + + ObjectMapper mapper = JsonMapper.builder() // or different mapper for other format + .addModule(new JavaTimeModule()) + // and possibly other configuration, modules, then: + .build(); + + // Create the transport with a Jackson mapper + ElasticsearchTransport transport = new RestClientTransport( + restClient, new JacksonJsonpMapper(mapper)); + + // And create the API client + ElasticsearchClient esClient = new ElasticsearchClient(transport); + + // check and/or create indexes + + // TODO remove, for testing +// esClient.indices().delete(del -> del +// .ignoreUnavailable(true) +// .index("users")); +// +// esClient.indices().delete(del -> del +// .ignoreUnavailable(true) +// .index("articles")); + + BooleanResponse indexResU = esClient.indices().exists(ex -> ex.index("users")); //TODO constant + if (!indexResU.value()) { + esClient.indices().create(c -> c + .index("users")); + } + + BooleanResponse indexResA = esClient.indices().exists(ex -> ex.index("articles")); //TODO constant + if (!indexResA.value()) { + esClient.indices().create(c -> c + .index("articles")); + } + + return esClient; + + } +} 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..591def4ec --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/db/UserService.java @@ -0,0 +1,253 @@ +package realworld.db; + +import co.elastic.clients.elasticsearch.ElasticsearchClient; +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.stereotype.Service; +import realworld.entity.exception.ResourceAlreadyExistsException; +import realworld.entity.exception.ResourceNotFoundException; +import realworld.entity.user.Profile; +import realworld.entity.user.UserDAO; +import realworld.entity.user.UserEntity; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +@Service +public class UserService { + + private ElasticsearchClient esClient; + + @Autowired + public UserService(ElasticsearchClient esClient) { + this.esClient = esClient; + } + + public UserDAO newUser(UserDAO user) throws IOException { + + // checking if username or password already used. + // using a "term" query to match the exact strings + // (a "match" query would also find words containing the value inserted) + // using "should" to find documents matching either condition. + + SearchResponse checkUser = esClient.search(ss -> ss + .index("users") + .query(q -> q + .bool(b -> b + .should(m -> m + .term(mc -> mc + .field("email.keyword") + .value(user.email())) + ).should(m -> m + .term(mc -> mc + .field("username.keyword") + .value(user.username()))))) + , UserEntity.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 + String jws = Jwts.builder() + .setIssuer("rw-backend") + .setSubject(user.email()) + .claim("name", user.username()) + .claim("scope", "user") + .setIssuedAt(Date.from(Instant.now())) + //.setExpiration(Date.from(Instant.now().plus(5, ChronoUnit.MINUTES))) + .signWith( + SignatureAlgorithm.HS256, + TextCodec.BASE64.decode("c3VjaGFteXN0ZXJ5b3Vyc3VwZXJzZWNyZXR3b3c=") + ) + .compact(); + + UserEntity ue = new UserEntity(user.username(), user.email(), + user.password(), jws, user.bio(), user.image(), new ArrayList<>()); + + // using the username as a document ID will help with operations that require the document ID + IndexRequest userReq = IndexRequest.of((id -> id + .index("users") + .id(ue.username()) + .document(ue))); + + esClient.index(userReq); + + return user; + } + + public UserDAO login(UserDAO user) throws IOException { + + // term query to match exactly the email and password strings, + // using "must" to match both + SearchResponse getUser = esClient.search(ss -> ss + .index("users") + .query(q -> q + .bool(b -> b + .must(m -> m + .term(mc -> mc + .field("email.keyword") + .value(user.email())) + ).must(m -> m + .term(mc -> mc + .field("password.keyword") + .value(user.password())))) + ) + , UserEntity.class); + + if(getUser.hits().hits().isEmpty()){ + throw new ResourceNotFoundException("Wrong email or password"); + } + + return new UserDAO(getUser.hits().hits().getFirst().source()); + } + + public UserDAO getUserFromToken(String auth) throws IOException { + return new UserDAO(getUserEntityFromToken(auth).hits().hits().getFirst().source()); + } + + private SearchResponse getUserEntityFromToken(String auth) throws IOException { + String token; + try { + token = auth.split(" ")[1]; + Jwts.parser() + .setSigningKey(TextCodec.BASE64.decode("c3VjaGFteXN0ZXJ5b3Vyc3VwZXJzZWNyZXR3b3c=")) + .parse(token); + } catch (Exception e) { + throw new RuntimeException("Token not recognised",e); + } + + SearchResponse getUser = esClient.search(ss -> ss + .index("users") + .query(q -> q + .term(m -> m + .field("token.keyword") + .value(token)) + ) + , UserEntity.class); + + if (getUser.hits().hits().isEmpty()) { + throw new ResourceNotFoundException("Token not assigned to any user"); + } + return getUser; + } + + public UserDAO updateUser(String auth, UserDAO user) throws IOException { + + SearchResponse userSearch = getUserEntityFromToken(auth); + UserEntity userEntity = userSearch.hits().hits().getFirst().source(); + + UserEntity ue = new UserEntity(userEntity.username(), user.email(), + userEntity.password(), userEntity.token(), user.bio(), user.image(), userEntity.following()); + + UpdateResponse upUser = esClient.update(up -> up + .index("users") + .id(ue.username()) + .doc(ue) + , UserEntity.class); + + return new UserDAO(ue); + } + + public Profile getUserProfile(String username, String auth) throws IOException { + + SearchResponse getUser = findUserByUsername(username); + + UserEntity targetUser = getUser.hits().hits().getFirst().source(); + + // checking if the user is followed by who's asking + SearchResponse askingUser = getUserEntityFromToken(auth); + boolean following = false; + if (askingUser.hits().hits().getFirst().source().following().contains(targetUser.username())) { + following = true; + } + + Profile targetUserProfile = new Profile(targetUser, following); + + return targetUserProfile; + } + + private SearchResponse findUserByUsername(String username) throws IOException { + // since the id is the unique username, using id query since it is the most efficient + SearchResponse getUser = esClient.search(ss -> ss + .index("users") + .query(q -> q + .ids(id -> id + .values(List.of(username))) + ) + , UserEntity.class); + return getUser; + } + + public Profile followUser(String username, String auth) throws IOException { + + SearchResponse getUser = findUserByUsername(username); + + UserEntity targetUser = getUser.hits().hits().getFirst().source(); + // add followed user to list if not already present + + SearchResponse userSearch = getUserEntityFromToken(auth); + UserEntity askingUser = userSearch.hits().hits().getFirst().source(); + + if (!askingUser.following().contains(targetUser.username())) { + askingUser.following().add(targetUser.username()); + + UpdateResponse upUser = esClient.update(up -> up //TODO check success + .index("users") + .id(username) + .doc(askingUser) + , UserEntity.class); + } + Profile targetUserProfile = new Profile(targetUser,true); + + return targetUserProfile; + } + + // TODO merge follow and unfollow + public Profile unfollowUser(String username, String auth) throws IOException { + + SearchResponse getUser = findUserByUsername(username); + + UserEntity targetUser = getUser.hits().hits().getFirst().source(); + + // remove followed user to list if not already present + + SearchResponse userSearch = getUserEntityFromToken(auth); + UserEntity askingUser = userSearch.hits().hits().getFirst().source(); + + if (askingUser.following().contains(targetUser.username())) { + askingUser.following().remove(targetUser.username()); + + UpdateResponse upUser = esClient.update(up -> up //TODO check success + .index("users") + .id(username) + .doc(askingUser) + , UserEntity.class); + } + Profile targetUserProfile = new Profile(targetUser,false); + + return targetUserProfile; + } +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Article.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Article.java new file mode 100644 index 000000000..6ef2e300d --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Article.java @@ -0,0 +1,12 @@ +package realworld.entity.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 java.util.List; + +@JsonTypeName("article") +@JsonTypeInfo(include= As.WRAPPER_OBJECT, use= Id.NAME) +public record Article (String title, String description, String body,List tagList){} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleDAO.java new file mode 100644 index 000000000..4b7664b33 --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleDAO.java @@ -0,0 +1,30 @@ +package realworld.entity.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 realworld.entity.user.Author; + +import java.time.Instant; +import java.util.List; + +@JsonTypeName("article") +@JsonTypeInfo(include = As.WRAPPER_OBJECT, use = Id.NAME) +public record ArticleDAO( + String slug, + String title, + String description, + String body, + List tagList, + Instant createdAt, + Instant updatedAt, + boolean favorited, + int favoritesCount, + Author author) { + + + public ArticleDAO(ArticleEntity 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/entity/article/ArticleEntity.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleEntity.java new file mode 100644 index 000000000..ffcba7ef1 --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleEntity.java @@ -0,0 +1,25 @@ +package realworld.entity.article; + +import realworld.entity.user.Author; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +public record ArticleEntity( + String slug, + String title, + String description, + String body, + List tagList, + Instant createdAt, + Instant updatedAt, + boolean favorited, + int favoritesCount, + List favoritedBy, + Author author) { + + public ArticleEntity(Article 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/entity/article/Articles.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Articles.java new file mode 100644 index 000000000..3c74495c1 --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Articles.java @@ -0,0 +1,5 @@ +package realworld.entity.article; + +import java.util.List; + +public record Articles (List articles, int articlesCount){} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/exception/ResourceAlreadyExistsException.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/exception/ResourceAlreadyExistsException.java new file mode 100644 index 000000000..65c397c41 --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/exception/ResourceAlreadyExistsException.java @@ -0,0 +1,8 @@ +package realworld.entity.exception; + +public class ResourceAlreadyExistsException extends RuntimeException{ + + public ResourceAlreadyExistsException(String message) { + super(message); + } +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/exception/ResourceNotFoundException.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/exception/ResourceNotFoundException.java new file mode 100644 index 000000000..ef32eda66 --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/exception/ResourceNotFoundException.java @@ -0,0 +1,8 @@ +package realworld.entity.exception; + +public class ResourceNotFoundException extends RuntimeException{ + + public ResourceNotFoundException(String message) { + super(message); + } +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Author.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Author.java new file mode 100644 index 000000000..24cbf7768 --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Author.java @@ -0,0 +1,23 @@ +package realworld.entity.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("author") +@JsonTypeInfo(include= As.WRAPPER_OBJECT, use= Id.NAME) +public record Author( + String username, + String email, + String bio, + boolean following){ + + public Author(UserEntity ue, boolean following) { + this(ue.username(), ue.email(), ue.bio(), following); + } + + public Author(UserDAO ue, boolean following) { + this(ue.username(), ue.email(), ue.bio(), following); + } +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Profile.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Profile.java new file mode 100644 index 000000000..3dca0ca52 --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Profile.java @@ -0,0 +1,19 @@ +package realworld.entity.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 email, + String bio, + boolean following){ + + public Profile(UserEntity ue, boolean following) { + this(ue.username(), ue.email(), ue.bio(), following); + } +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/User.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/User.java new file mode 100644 index 000000000..e265c1ed6 --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/User.java @@ -0,0 +1,22 @@ +package realworld.entity.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 User( + + String username, + String email, + String token, + String bio, + String image) { + + public User(UserDAO userDAO) { + this(userDAO.username(), userDAO.email(), userDAO.token(), userDAO.bio(), userDAO.image()); + } + +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserDAO.java new file mode 100644 index 000000000..65552456a --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserDAO.java @@ -0,0 +1,22 @@ +package realworld.entity.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 UserDAO( + + String username, + String email, + String password, + String token, + String bio, + String image){ + + public UserDAO(UserEntity ue){ + this(ue.username(), ue.email(), ue.password(), ue.token(), ue.bio(), ue.image()); + } +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserEntity.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserEntity.java new file mode 100644 index 000000000..96cfc70c0 --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserEntity.java @@ -0,0 +1,14 @@ +package realworld.entity.user; + +import java.util.List; + +public record UserEntity ( + + String username, + String email, + String password, + String token, + String bio, + String image, + List following){ +} diff --git a/examples/realworld-app/rw-rest/build.gradle b/examples/realworld-app/rw-rest/build.gradle new file mode 100644 index 000000000..054924722 --- /dev/null +++ b/examples/realworld-app/rw-rest/build.gradle @@ -0,0 +1,25 @@ +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') +} + +tasks.named('test') { + useJUnitPlatform() +} 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..09e13c209 --- /dev/null +++ b/examples/realworld-app/rw-rest/src/main/java/realworld/rest/ArticleController.java @@ -0,0 +1,84 @@ +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.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import realworld.db.ArticleService; +import realworld.entity.article.Article; +import realworld.entity.article.ArticleDAO; +import realworld.entity.article.ArticleEntity; +import realworld.entity.article.Articles; + +import java.io.IOException; +import java.util.List; + +@RestController +@RequestMapping() +public class ArticleController { + + private ArticleService service; + + Logger logger = LoggerFactory.getLogger(UserController.class); + + @Autowired + public ArticleController(ArticleService service) { + this.service = service; + } + + @PostMapping("/articles") + public ResponseEntity createArticle(@RequestHeader("Authorization") String auth, @RequestBody Article req) throws IOException { + // TODO check null + // TODO consider adding validator + ArticleEntity res = service.newArticle(req, auth); + logger.debug("Created new article: {}", res.slug()); + return ResponseEntity.ok(new ArticleDAO(res)); + } + + @GetMapping("/articles/{slug}") + public ResponseEntity getArticleBySlug(@PathVariable String slug) throws IOException { + ArticleEntity res = service.getArticleBySlug(slug); + logger.debug("Retrieved article: {}", res.slug()); + return ResponseEntity.ok(new ArticleDAO(res)); + } + + @GetMapping("/articles") + public ResponseEntity getArticles(@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) throws IOException { + Articles res = service.getArticles(tag, author, favorited, limit, offset); + logger.debug("Returned article list"); + return ResponseEntity.ok(res); + } + + @PostMapping("/articles/{slug}/favorite") + public ResponseEntity favoriteArticle(@RequestHeader("Authorization") String auth, @PathVariable String slug) throws IOException { + ArticleEntity res = service.favoriteArticle(slug, auth); + logger.debug("Set article: {} as favorite", res.slug()); + return ResponseEntity.ok(new ArticleDAO(res)); + } + + @DeleteMapping("/articles/{slug}/favorite") + public ResponseEntity unfavoriteArticle(@RequestHeader("Authorization") String auth, @PathVariable String slug) throws IOException { + ArticleEntity res = service.unfavoriteArticle(slug, auth); + logger.debug("Removed article: {} from favorites", res.slug()); + return ResponseEntity.ok(new ArticleDAO(res)); + } + + //TODO REMOVE, JUST FOR TEST + @GetMapping("/allarticles") + public ResponseEntity> getAllArticlesTest() throws IOException { + List res = service.allArticles(); + logger.debug("Returned article list"); + return ResponseEntity.ok(res); + } +} 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..9cb99266c --- /dev/null +++ b/examples/realworld-app/rw-rest/src/main/java/realworld/rest/ProfileController.java @@ -0,0 +1,53 @@ +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.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import realworld.db.UserService; +import realworld.entity.user.Profile; + +import java.io.IOException; + + +@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.getUserProfile(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/UserController.java b/examples/realworld-app/rw-rest/src/main/java/realworld/rest/UserController.java new file mode 100644 index 000000000..309cf98e2 --- /dev/null +++ b/examples/realworld-app/rw-rest/src/main/java/realworld/rest/UserController.java @@ -0,0 +1,81 @@ +package realworld.rest; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import realworld.db.UserService; +import realworld.entity.user.User; +import realworld.entity.user.UserDAO; + +import java.io.IOException; +import java.util.Objects; + + +@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 UserDAO req) throws IOException { + // TODO consider adding validator + if (isNullOrBlank(req.email()) || isNullOrBlank(req.username()) || isNullOrBlank(req.password())) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .header("EXCEPTION", "missing required field(s)") + .body(null); + } + UserDAO res = service.newUser(req); + logger.debug("Registered new user {}", req.username()); + return ResponseEntity.ok(new User(res)); + } + + @PostMapping("users/login") + public ResponseEntity login(@RequestBody UserDAO req) throws IOException { + if (isNullOrBlank(req.email()) || isNullOrBlank(req.password())) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .header("EXCEPTION", "missing required field(s)") + .body(null); + } + UserDAO res = service.login(req); + logger.debug("User {} logged in", req.username()); + return ResponseEntity.ok(new User(res)); + } + + @GetMapping("/user") + public ResponseEntity get(@RequestHeader("Authorization") String auth) throws IOException { + UserDAO res = service.getUserFromToken(auth); + logger.debug("Returning info about user {}", res.username()); + return ResponseEntity.ok(new User(res)); + + } + + @PutMapping("/user") + public ResponseEntity update(@RequestHeader("Authorization") String auth, @RequestBody UserDAO req) throws IOException { + UserDAO res = service.updateUser(auth, req); + logger.debug("Updated info for user {}", req.username()); + return ResponseEntity.ok(new User(res)); + + } + + //TODO common utility class + private boolean isNullOrBlank(String s) { + return Objects.isNull(s) || s.isBlank(); + } +} 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..d39f33ee0 --- /dev/null +++ b/examples/realworld-app/rw-rest/src/main/java/realworld/rest/error/RestError.java @@ -0,0 +1,13 @@ +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..e0edb70ab --- /dev/null +++ b/examples/realworld-app/rw-rest/src/main/java/realworld/rest/error/RestExceptionHandler.java @@ -0,0 +1,43 @@ +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.entity.exception.ResourceAlreadyExistsException; +import realworld.entity.exception.ResourceNotFoundException; + +import java.io.IOException; +import java.util.List; + +@ControllerAdvice +public class RestExceptionHandler + extends ResponseEntityExceptionHandler { + + @ExceptionHandler(value + = { IOException.class}) + protected ResponseEntity handleIo( + RuntimeException ex, WebRequest request) { + return handleExceptionInternal(ex, new RestError(List.of("Connection Error with the Database")), + new HttpHeaders(), HttpStatus.INTERNAL_SERVER_ERROR, request); + } + + @ExceptionHandler(value + = { ResourceAlreadyExistsException.class}) + protected ResponseEntity handleConflict( + RuntimeException ex, WebRequest request) { + return handleExceptionInternal(ex, new RestError(List.of(ex.getLocalizedMessage())), + new HttpHeaders(), HttpStatus.CONFLICT, request); + } + + @ExceptionHandler(value + = { ResourceNotFoundException.class}) + protected ResponseEntity handleNotFound( + RuntimeException ex, WebRequest request) { + return handleExceptionInternal(ex, new RestError(List.of(ex.getLocalizedMessage())), + new HttpHeaders(), HttpStatus.NOT_FOUND, request); + } +} diff --git a/examples/realworld-app/rw-server/build.gradle b/examples/realworld-app/rw-server/build.gradle new file mode 100644 index 000000000..9f82910e9 --- /dev/null +++ b/examples/realworld-app/rw-server/build.gradle @@ -0,0 +1,26 @@ +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:3.2.0' + implementation ('realworldapp:rw-rest') + +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/examples/realworld-app/rw-server/src/main/java/realworld/SpringBootApp.java b/examples/realworld-app/rw-server/src/main/java/realworld/SpringBootApp.java new file mode 100644 index 000000000..2d98e80bd --- /dev/null +++ b/examples/realworld-app/rw-server/src/main/java/realworld/SpringBootApp.java @@ -0,0 +1,15 @@ +package realworld; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import realworld.config.DefaultProperties; + +@SpringBootApplication +public class SpringBootApp { + + public static void main(String[] args) { + SpringApplication app = new SpringApplication(SpringBootApp.class); + app.setDefaultProperties(DefaultProperties.getDefaultProperties()); + app.run(args); + } +} diff --git a/examples/realworld-app/rw-server/src/main/java/realworld/config/DefaultProperties.java b/examples/realworld-app/rw-server/src/main/java/realworld/config/DefaultProperties.java new file mode 100644 index 000000000..644610276 --- /dev/null +++ b/examples/realworld-app/rw-server/src/main/java/realworld/config/DefaultProperties.java @@ -0,0 +1,19 @@ +package realworld.config; + +import java.util.Properties; + +public class DefaultProperties { + private DefaultProperties() { + } + + public static Properties getDefaultProperties() { + Properties p = new Properties(); + p.setProperty("server.address", "0.0.0.0"); + p.setProperty("server.port", "8080"); + p.setProperty("server.scheme", "http"); + p.setProperty("server.servlet.context-path", "/api"); + p.setProperty("spring.output.ansi.enabled", "ALWAYS"); + return p; + } + +} diff --git a/examples/realworld-app/rw-server/src/main/resources/application.properties b/examples/realworld-app/rw-server/src/main/resources/application.properties new file mode 100644 index 000000000..cb2b098b6 --- /dev/null +++ b/examples/realworld-app/rw-server/src/main/resources/application.properties @@ -0,0 +1,10 @@ +### +# Application Settings +### +server.port=8080 +server.address=localhost +### +# Elasticsearch Settings +### +elasticsearch.server.url=http://localhost:9200 +elasticsearch.api.key=VnVhQ2ZHY0JDZGJrU... diff --git a/examples/realworld-app/settings.gradle b/examples/realworld-app/settings.gradle new file mode 100644 index 000000000..83bf3da24 --- /dev/null +++ b/examples/realworld-app/settings.gradle @@ -0,0 +1,5 @@ +rootProject.name = 'realworld-app' +includeBuild('rw-server') +includeBuild('rw-database') +includeBuild('rw-rest') + From 881ce7d83d206ff9b7326457b07f335b6436e4e1 Mon Sep 17 00:00:00 2001 From: Laura Date: Mon, 8 Jan 2024 18:24:14 +0100 Subject: [PATCH 02/22] WIP more article crud (update, complete fav/unfav) some refactor --- .../java/realworld/db/ArticleService.java | 164 ++++++++++++++---- .../main/java/realworld/db/UserService.java | 102 +++++++---- .../entity/article/ArticleUpdateDAO.java | 10 ++ .../java/realworld/entity/article/Tags.java | 5 + .../exception/UnauthorizedException.java | 8 + .../java/realworld/entity/user/LoginDAO.java | 4 + .../realworld/entity/user/RegisterDAO.java | 4 + .../main/java/realworld/utils/Utility.java | 21 +++ .../realworld/rest/ArticleController.java | 9 + .../java/realworld/rest/TagsController.java | 40 +++++ .../java/realworld/rest/UserController.java | 9 +- 11 files changed, 305 insertions(+), 71 deletions(-) create mode 100644 examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleUpdateDAO.java create mode 100644 examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Tags.java create mode 100644 examples/realworld-app/rw-database/src/main/java/realworld/entity/exception/UnauthorizedException.java create mode 100644 examples/realworld-app/rw-database/src/main/java/realworld/entity/user/LoginDAO.java create mode 100644 examples/realworld-app/rw-database/src/main/java/realworld/entity/user/RegisterDAO.java create mode 100644 examples/realworld-app/rw-database/src/main/java/realworld/utils/Utility.java create mode 100644 examples/realworld-app/rw-rest/src/main/java/realworld/rest/TagsController.java diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java b/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java index fbbf5fdd1..a0787e0e1 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java @@ -1,28 +1,40 @@ package realworld.db; import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.elasticsearch._types.SortOrder; +import co.elastic.clients.elasticsearch._types.aggregations.Aggregation; import co.elastic.clients.elasticsearch._types.query_dsl.MatchQuery.Builder; import co.elastic.clients.elasticsearch._types.query_dsl.Query; 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 co.elastic.clients.util.NamedValue; import com.github.slugify.Slugify; +import org.elasticsearch.client.ResponseException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import realworld.entity.article.Article; import realworld.entity.article.ArticleDAO; import realworld.entity.article.ArticleEntity; +import realworld.entity.article.ArticleUpdateDAO; import realworld.entity.article.Articles; +import realworld.entity.article.Tags; import realworld.entity.exception.ResourceAlreadyExistsException; +import realworld.entity.exception.ResourceNotFoundException; +import realworld.entity.exception.UnauthorizedException; import realworld.entity.user.Author; import realworld.entity.user.UserDAO; +import static realworld.utils.Utility.extractId; +import static realworld.utils.Utility.extractSource; +import static realworld.utils.Utility.isNullOrBlank; + import java.io.IOException; import java.time.Instant; import java.util.ArrayList; import java.util.Collections; import java.util.List; -import java.util.Objects; import java.util.stream.Collectors; @Service @@ -40,17 +52,14 @@ public ArticleService(ElasticsearchClient esClient, UserService userService) { public ArticleEntity newArticle(Article article, String auth) throws IOException { // checking if slug would be unique - String slug = Slugify.builder().build().slugify(article.title()); - if (!singleArticleBySlug(slug).hits().hits().isEmpty()) { - throw new ResourceAlreadyExistsException("Article slug already exists, please change the title"); - } + String slug = generateAndCheckSlug(article.title()); // getting the author UserDAO ue = userService.getUserFromToken(auth); - Author author = new Author(ue,false); + Author author = new Author(ue, false); Instant now = Instant.now(); - ArticleEntity articleEntity = new ArticleEntity(article,slug,now,now,author); + ArticleEntity articleEntity = new ArticleEntity(article, slug, now, now, author); IndexRequest articleReq = IndexRequest.of((id -> id .index("articles") @@ -77,45 +86,118 @@ public SearchResponse singleArticleBySlug(String slug) throws IOE } public ArticleEntity getArticleBySlug(String slug) throws IOException { + SearchResponse articleSearch = getArticleEntitySearchResponse(slug); + return extractSource(articleSearch); + } + + private SearchResponse getArticleEntitySearchResponse(String slug) throws IOException { SearchResponse articleSearch = singleArticleBySlug(slug); if (articleSearch.hits().hits().isEmpty()) { - throw new RuntimeException("Article not found"); + throw new ResourceNotFoundException("Article not found"); + } + return articleSearch; + } + + public ArticleDAO updateArticle(ArticleUpdateDAO article, String auth, String slug) throws IOException { + + // getting original article from slug + SearchResponse articleSearch = getArticleEntitySearchResponse(slug); + String id = extractId(articleSearch); + ArticleEntity oldArticle = extractSource(articleSearch); + + // checking if author is the same + UserDAO ue = userService.getUserFromToken(auth); + Author author = new Author(ue, false); + + 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())) { + newSlug = generateAndCheckSlug(article.title()); + } + + Instant updatedAt = Instant.now(); + + ArticleEntity updatedArticle = new ArticleEntity(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 ArticleDAO(updatedArticle); + } + + private String generateAndCheckSlug(String article) throws IOException { + String slug = Slugify.builder().build().slugify(article); + if (!singleArticleBySlug(slug).hits().hits().isEmpty()) { + throw new ResourceAlreadyExistsException("Article slug already exists, please change the title"); } - return articleSearch.hits().hits().getFirst().source(); + return slug; } public ArticleEntity favoriteArticle(String slug, String auth) throws IOException { UserDAO user = userService.getUserFromToken(auth); - SearchResponse articleSearch = singleArticleBySlug(slug); - if (articleSearch.hits().hits().isEmpty()) { - throw new RuntimeException("Article not found"); + SearchResponse articleSearch = getArticleEntitySearchResponse(slug); + String id = extractId(articleSearch); + ArticleEntity article = extractSource(articleSearch); + + // checking if article was already favorited + if (article.favoritedBy().contains(user.username())) { + return article; } - ArticleEntity article = articleSearch.hits().hits().getFirst().source(); + article.favoritedBy().add(user.username()); - article = new ArticleEntity(article.slug(), article.title(), article.description(), article.body(), article.tagList(), article.createdAt(), article.updatedAt(), true, article.favoritesCount()+1, article.favoritedBy(), article.author()); - //TODO update in db + ArticleEntity updatedArticle = new ArticleEntity(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 article; } public ArticleEntity unfavoriteArticle(String slug, String auth) throws IOException { UserDAO user = userService.getUserFromToken(auth); - SearchResponse articleSearch = singleArticleBySlug(slug); - if (articleSearch.hits().hits().isEmpty()) { - throw new RuntimeException("Article not found"); + SearchResponse articleSearch = getArticleEntitySearchResponse(slug); + String id = extractId(articleSearch); + ArticleEntity article = extractSource(articleSearch); + + // checking if article wasn't favorited before + if (!article.favoritedBy().contains(user.username())) { + return article; } - ArticleEntity article = articleSearch.hits().hits().getFirst().source(); - // TODO check if it's favorited before + article.favoritedBy().remove(user.username()); - int favoriteCount = article.favoritesCount()-1; + int favoriteCount = article.favoritesCount() - 1; boolean favorited = article.favorited(); - if(favoriteCount==0){ - favorited=false; + if (favoriteCount == 0) { + favorited = false; + } + + ArticleEntity updatedArticle = new ArticleEntity(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; + } + + private void updateArticle(String id, ArticleEntity updatedArticle) throws IOException { + UpdateResponse upArticle = esClient.update(up -> up + .index("articles") + .id(id) + .doc(updatedArticle) + , ArticleEntity.class); + if (!upArticle.result().name().equals("Updated")) { + throw new RuntimeException("Article update failed"); } - // TODO update in db - article = new ArticleEntity(article.slug(), article.title(), article.description(), article.body(), article.tagList(), article.createdAt(), article.updatedAt(), favorited, favoriteCount, article.favoritedBy(), article.author()); - return article; } public Articles getArticles(String tag, String author, String favorited, Integer limit, Integer offset) throws IOException { @@ -151,7 +233,7 @@ public Articles getArticles(String tag, String author, String favorited, Integer .map(Hit::source) // if tag specified, put that tag first in the array .peek(a -> { - if(!isNullOrBlank(tag)&&a.tagList().contains(tag)) { + if (!isNullOrBlank(tag) && a.tagList().contains(tag)) { Collections.swap(a.tagList(), a.tagList().indexOf(tag), 0); } }) @@ -159,6 +241,30 @@ public Articles getArticles(String tag, String author, String favorited, Integer .collect(Collectors.toList()), getArticle.hits().hits().size()); } + // TODO test sort by doc count + NamedValue sort = new NamedValue<>("_key", SortOrder.Asc); + + // TODO explain + public Tags allTags() throws IOException { + SearchResponse aggregateTags = esClient.search(s -> s + .index("articles") + .size(0) // this is to only return aggregation result, and not also search result + .aggregations("tags", agg -> agg + .terms(ter -> ter + .field("tagList.keyword") + .order(sort)) + ), + Aggregation.class + ); + + return new Tags(aggregateTags.aggregations().get("tags") + .sterms().buckets() + .array().stream() + .map(st -> st.key().stringValue()) + .collect(Collectors.toList()) + ); + } + // TODO remove public List allArticles() throws IOException { SearchResponse getArticle = esClient.search(ss -> ss @@ -173,9 +279,5 @@ public List allArticles() throws IOException { .map(Hit::source) .collect(Collectors.toList()); } - //TODO common utility class - private boolean isNullOrBlank(String s) { - return Objects.isNull(s) || s.isBlank(); - } } 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 index 591def4ec..39474c456 100644 --- 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 @@ -13,15 +13,18 @@ import realworld.entity.exception.ResourceAlreadyExistsException; import realworld.entity.exception.ResourceNotFoundException; import realworld.entity.user.Profile; +import realworld.entity.user.User; import realworld.entity.user.UserDAO; import realworld.entity.user.UserEntity; -import java.io.FileNotFoundException; import java.io.IOException; import java.time.Instant; import java.util.ArrayList; import java.util.Date; -import java.util.List; + +import static realworld.utils.Utility.extractId; +import static realworld.utils.Utility.extractSource; +import static realworld.utils.Utility.isNullOrBlank; @Service public class UserService { @@ -35,11 +38,13 @@ public UserService(ElasticsearchClient esClient) { public UserDAO newUser(UserDAO user) throws IOException { - // checking if username or password already used. + // checking if username or email already used. // using a "term" query to match the exact strings // (a "match" query would also find words containing the value inserted) // using "should" to find documents matching either condition. + // TODO explain keyword + SearchResponse checkUser = esClient.search(ss -> ss .index("users") .query(q -> q @@ -87,10 +92,8 @@ public UserDAO newUser(UserDAO user) throws IOException { UserEntity ue = new UserEntity(user.username(), user.email(), user.password(), jws, user.bio(), user.image(), new ArrayList<>()); - // using the username as a document ID will help with operations that require the document ID IndexRequest userReq = IndexRequest.of((id -> id .index("users") - .id(ue.username()) .document(ue))); esClient.index(userReq); @@ -121,11 +124,11 @@ public UserDAO login(UserDAO user) throws IOException { throw new ResourceNotFoundException("Wrong email or password"); } - return new UserDAO(getUser.hits().hits().getFirst().source()); + return new UserDAO(extractSource(getUser)); } public UserDAO getUserFromToken(String auth) throws IOException { - return new UserDAO(getUserEntityFromToken(auth).hits().hits().getFirst().source()); + return new UserDAO(extractSource(getUserEntityFromToken(auth))); } private SearchResponse getUserEntityFromToken(String auth) throws IOException { @@ -154,33 +157,60 @@ private SearchResponse getUserEntityFromToken(String auth) throws IO return getUser; } - public UserDAO updateUser(String auth, UserDAO user) throws IOException { + public UserDAO updateUser(String auth, User user) throws IOException { SearchResponse userSearch = getUserEntityFromToken(auth); - UserEntity userEntity = userSearch.hits().hits().getFirst().source(); + String id = extractId(userSearch); + UserEntity userEntity = extractSource(userSearch); + + // if the username or email are updated, checking uniqueness + if(!isNullOrBlank(user.username())){ + SearchResponse newUsernameSearch = findUserByUsername(user.username()); + if(!newUsernameSearch.hits().hits().isEmpty()){ + throw new ResourceAlreadyExistsException("Username already exists"); + } + } + + if(!isNullOrBlank(user.email())){ + SearchResponse newEmailSearch = findUserByEmail(user.email()); + if(!newEmailSearch.hits().hits().isEmpty()){ + throw new ResourceAlreadyExistsException("Email already in use"); + } + } - UserEntity ue = new UserEntity(userEntity.username(), user.email(), - userEntity.password(), userEntity.token(), user.bio(), user.image(), userEntity.following()); + // null/black check for every optional field + UserEntity ue = new UserEntity(isNullOrBlank(user.username()) ? user.username() : userEntity.username(), + isNullOrBlank(user.email()) ? userEntity.email() : user.email(), + userEntity.password(), userEntity.token(), + isNullOrBlank(user.bio()) ? userEntity.bio() : user.bio(), + isNullOrBlank(user.image()) ? userEntity.image() : user.image(), + userEntity.following()); + updateUser(id, ue); + return new UserDAO(ue); + } + + private void updateUser(String id, UserEntity ue) throws IOException { UpdateResponse upUser = esClient.update(up -> up .index("users") - .id(ue.username()) + .id(id) .doc(ue) , UserEntity.class); - - return new UserDAO(ue); + if(!upUser.result().name().equals("Updated")){ + throw new RuntimeException("Article update failed"); + } } public Profile getUserProfile(String username, String auth) throws IOException { SearchResponse getUser = findUserByUsername(username); - UserEntity targetUser = getUser.hits().hits().getFirst().source(); + UserEntity targetUser = extractSource(getUser); // checking if the user is followed by who's asking SearchResponse askingUser = getUserEntityFromToken(auth); boolean following = false; - if (askingUser.hits().hits().getFirst().source().following().contains(targetUser.username())) { + if (extractSource(askingUser).following().contains(targetUser.username())) { following = true; } @@ -190,13 +220,25 @@ public Profile getUserProfile(String username, String auth) throws IOException { } private SearchResponse findUserByUsername(String username) throws IOException { - // since the id is the unique username, using id query since it is the most efficient + // simple term query to match exactly the username string SearchResponse getUser = esClient.search(ss -> ss .index("users") .query(q -> q - .ids(id -> id - .values(List.of(username))) - ) + .term(t -> t + .field("username.keyword") + .value(username))) + , UserEntity.class); + return getUser; + } + + private SearchResponse 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))) , UserEntity.class); return getUser; } @@ -205,20 +247,16 @@ public Profile followUser(String username, String auth) throws IOException { SearchResponse getUser = findUserByUsername(username); - UserEntity targetUser = getUser.hits().hits().getFirst().source(); + UserEntity targetUser = extractSource(getUser); // add followed user to list if not already present SearchResponse userSearch = getUserEntityFromToken(auth); - UserEntity askingUser = userSearch.hits().hits().getFirst().source(); + UserEntity askingUser = extractSource(userSearch); if (!askingUser.following().contains(targetUser.username())) { askingUser.following().add(targetUser.username()); - UpdateResponse upUser = esClient.update(up -> up //TODO check success - .index("users") - .id(username) - .doc(askingUser) - , UserEntity.class); + updateUser(username, askingUser); } Profile targetUserProfile = new Profile(targetUser,true); @@ -230,21 +268,17 @@ public Profile unfollowUser(String username, String auth) throws IOException { SearchResponse getUser = findUserByUsername(username); - UserEntity targetUser = getUser.hits().hits().getFirst().source(); + UserEntity targetUser = extractSource(getUser); // remove followed user to list if not already present SearchResponse userSearch = getUserEntityFromToken(auth); - UserEntity askingUser = userSearch.hits().hits().getFirst().source(); + UserEntity askingUser = extractSource(userSearch); if (askingUser.following().contains(targetUser.username())) { askingUser.following().remove(targetUser.username()); - UpdateResponse upUser = esClient.update(up -> up //TODO check success - .index("users") - .id(username) - .doc(askingUser) - , UserEntity.class); + updateUser(username, askingUser); } Profile targetUserProfile = new Profile(targetUser,false); diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleUpdateDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleUpdateDAO.java new file mode 100644 index 000000000..96e815e2f --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleUpdateDAO.java @@ -0,0 +1,10 @@ +package realworld.entity.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 ArticleUpdateDAO (String title, String description, String body){} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Tags.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Tags.java new file mode 100644 index 000000000..ef7e69726 --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Tags.java @@ -0,0 +1,5 @@ +package realworld.entity.article; + +import java.util.List; + +public record Tags (List tags){} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/exception/UnauthorizedException.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/exception/UnauthorizedException.java new file mode 100644 index 000000000..746aa5319 --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/exception/UnauthorizedException.java @@ -0,0 +1,8 @@ +package realworld.entity.exception; + +public class UnauthorizedException extends RuntimeException{ + + public UnauthorizedException(String message) { + super(message); + } +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/LoginDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/LoginDAO.java new file mode 100644 index 000000000..5dac8e870 --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/LoginDAO.java @@ -0,0 +1,4 @@ +package realworld.entity.user; + +public class LoginDAO { +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/RegisterDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/RegisterDAO.java new file mode 100644 index 000000000..8fd76d31b --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/RegisterDAO.java @@ -0,0 +1,4 @@ +package realworld.entity.user; + +public class RegisterDAO { +} 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..4429c7bfd --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/utils/Utility.java @@ -0,0 +1,21 @@ +package realworld.utils; + +import co.elastic.clients.elasticsearch.core.SearchResponse; +import realworld.entity.user.UserEntity; + +import java.util.Objects; + +public class Utility { + + public static boolean isNullOrBlank(String s) { + return Objects.isNull(s) || s.isBlank(); + } + + public static String extractId(SearchResponse searchResponse) { + return searchResponse.hits().hits().getFirst().id(); + } + + public static TDocument extractSource(SearchResponse searchResponse) { + return searchResponse.hits().hits().getFirst().source(); + } +} 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 index 09e13c209..04817a56f 100644 --- 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 @@ -8,6 +8,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; @@ -17,6 +18,7 @@ import realworld.entity.article.Article; import realworld.entity.article.ArticleDAO; import realworld.entity.article.ArticleEntity; +import realworld.entity.article.ArticleUpdateDAO; import realworld.entity.article.Articles; import java.io.IOException; @@ -74,6 +76,13 @@ public ResponseEntity unfavoriteArticle(@RequestHeader("Authorizatio return ResponseEntity.ok(new ArticleDAO(res)); } + @PutMapping("/articles/{slug}") + public ResponseEntity updateArticle(@RequestHeader("Authorization") String auth, @RequestBody ArticleUpdateDAO req, @PathVariable String slug) throws IOException { + ArticleDAO res = service.updateArticle(req, auth, slug); + logger.debug("Set article: {} as favorite", res.slug()); + return ResponseEntity.ok(res); + } + //TODO REMOVE, JUST FOR TEST @GetMapping("/allarticles") public ResponseEntity> getAllArticlesTest() throws IOException { 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..bf84f4e0e --- /dev/null +++ b/examples/realworld-app/rw-rest/src/main/java/realworld/rest/TagsController.java @@ -0,0 +1,40 @@ +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.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import realworld.db.ArticleService; +import realworld.db.UserService; +import realworld.entity.article.Tags; +import realworld.entity.user.Profile; + +import java.io.IOException; + + +@RestController +@RequestMapping("/tags") +public class TagsController { + + private ArticleService service; + + Logger logger = LoggerFactory.getLogger(UserController.class); + + @Autowired + public TagsController(ArticleService service) { + this.service = service; + } + + @GetMapping() + public ResponseEntity get() throws IOException { + Tags res = service.allTags(); + 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 index 309cf98e2..879d12067 100644 --- 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 @@ -19,6 +19,8 @@ import java.io.IOException; import java.util.Objects; +import static realworld.utils.Utility.isNullOrBlank; + @RestController @RequestMapping() @@ -67,15 +69,10 @@ public ResponseEntity get(@RequestHeader("Authorization") String auth) thr } @PutMapping("/user") - public ResponseEntity update(@RequestHeader("Authorization") String auth, @RequestBody UserDAO req) throws IOException { + public ResponseEntity update(@RequestHeader("Authorization") String auth, @RequestBody User req) throws IOException { UserDAO res = service.updateUser(auth, req); logger.debug("Updated info for user {}", req.username()); return ResponseEntity.ok(new User(res)); } - - //TODO common utility class - private boolean isNullOrBlank(String s) { - return Objects.isNull(s) || s.isBlank(); - } } From becb13ac01a06d0263f29f954df76d32e9232ed0 Mon Sep 17 00:00:00 2001 From: Laura Date: Tue, 9 Jan 2024 12:10:26 +0100 Subject: [PATCH 03/22] WIP various bugfixes, making responses compatible with API --- .../java/realworld/db/ArticleService.java | 17 ++++---- .../main/java/realworld/db/UserService.java | 40 +++++++++++-------- .../{Article.java => ArticleCreationDAO.java} | 2 +- .../entity/article/ArticleEntity.java | 2 +- .../entity/article/ArticleForListDAO.java | 24 +++++++++++ .../realworld/entity/article/Articles.java | 2 +- .../java/realworld/entity/user/Profile.java | 4 +- .../realworld/rest/ArticleController.java | 4 +- 8 files changed, 64 insertions(+), 31 deletions(-) rename examples/realworld-app/rw-database/src/main/java/realworld/entity/article/{Article.java => ArticleCreationDAO.java} (77%) create mode 100644 examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleForListDAO.java diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java b/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java index a0787e0e1..83131954a 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java @@ -11,12 +11,12 @@ import co.elastic.clients.elasticsearch.core.search.Hit; import co.elastic.clients.util.NamedValue; import com.github.slugify.Slugify; -import org.elasticsearch.client.ResponseException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; -import realworld.entity.article.Article; +import realworld.entity.article.ArticleCreationDAO; import realworld.entity.article.ArticleDAO; import realworld.entity.article.ArticleEntity; +import realworld.entity.article.ArticleForListDAO; import realworld.entity.article.ArticleUpdateDAO; import realworld.entity.article.Articles; import realworld.entity.article.Tags; @@ -49,7 +49,7 @@ public ArticleService(ElasticsearchClient esClient, UserService userService) { this.userService = userService; } - public ArticleEntity newArticle(Article article, String auth) throws IOException { + public ArticleEntity newArticle(ArticleCreationDAO article, String auth) throws IOException { // checking if slug would be unique String slug = generateAndCheckSlug(article.title()); @@ -159,7 +159,7 @@ public ArticleEntity favoriteArticle(String slug, String auth) throws IOExceptio true, article.favoritesCount() + 1, article.favoritedBy(), article.author()); updateArticle(id, updatedArticle); - return article; + return updatedArticle; } public ArticleEntity unfavoriteArticle(String slug, String auth) throws IOException { @@ -237,14 +237,15 @@ public Articles getArticles(String tag, String author, String favorited, Integer Collections.swap(a.tagList(), a.tagList().indexOf(tag), 0); } }) - .map(ArticleDAO::new) + .map(ArticleForListDAO::new) .collect(Collectors.toList()), getArticle.hits().hits().size()); } - // TODO test sort by doc count - NamedValue sort = new NamedValue<>("_key", SortOrder.Asc); + // since the API definition doesn't specify the return order of tags, sorting by document count using "_count" + // if alphabetical order is preferred, use "_key" instead + NamedValue sort = new NamedValue<>("_count", SortOrder.Asc); - // TODO explain + // using a term aggregation is the simplest way to find every distinct tag for each article public Tags allTags() throws IOException { SearchResponse aggregateTags = esClient.search(s -> s .index("articles") 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 index 39474c456..5886c42e5 100644 --- 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 @@ -164,22 +164,22 @@ public UserDAO updateUser(String auth, User user) throws IOException { UserEntity userEntity = extractSource(userSearch); // if the username or email are updated, checking uniqueness - if(!isNullOrBlank(user.username())){ + if(!isNullOrBlank(user.username())&&!user.username().equals(userEntity.username())){ SearchResponse newUsernameSearch = findUserByUsername(user.username()); if(!newUsernameSearch.hits().hits().isEmpty()){ throw new ResourceAlreadyExistsException("Username already exists"); } } - if(!isNullOrBlank(user.email())){ + if(!isNullOrBlank(user.email())&&!user.email().equals(userEntity.email())){ SearchResponse newEmailSearch = findUserByEmail(user.email()); if(!newEmailSearch.hits().hits().isEmpty()){ throw new ResourceAlreadyExistsException("Email already in use"); } } - // null/black check for every optional field - UserEntity ue = new UserEntity(isNullOrBlank(user.username()) ? user.username() : userEntity.username(), + // null/blank check for every optional field + UserEntity ue = new UserEntity(isNullOrBlank(user.username()) ? userEntity.username() : user.username(), isNullOrBlank(user.email()) ? userEntity.email() : user.email(), userEntity.password(), userEntity.token(), isNullOrBlank(user.bio()) ? userEntity.bio() : user.bio(), @@ -197,13 +197,16 @@ private void updateUser(String id, UserEntity ue) throws IOException { .doc(ue) , UserEntity.class); if(!upUser.result().name().equals("Updated")){ - throw new RuntimeException("Article update failed"); + throw new RuntimeException("User update failed"); } } public Profile getUserProfile(String username, String auth) throws IOException { SearchResponse getUser = findUserByUsername(username); + if (getUser.hits().hits().isEmpty()) { + throw new ResourceNotFoundException("Target user not found"); + } UserEntity targetUser = extractSource(getUser); @@ -245,40 +248,45 @@ private SearchResponse findUserByEmail(String email) throws IOExcept public Profile followUser(String username, String auth) throws IOException { - SearchResponse getUser = findUserByUsername(username); + SearchResponse targetUserSearch = findUserByUsername(username); + if (targetUserSearch.hits().hits().isEmpty()) { + throw new ResourceNotFoundException("Target user not found"); + } - UserEntity targetUser = extractSource(getUser); + UserEntity targetUser = extractSource(targetUserSearch); // add followed user to list if not already present - SearchResponse userSearch = getUserEntityFromToken(auth); - UserEntity askingUser = extractSource(userSearch); + SearchResponse askingUserSearch = getUserEntityFromToken(auth); + UserEntity askingUser = extractSource(askingUserSearch); if (!askingUser.following().contains(targetUser.username())) { askingUser.following().add(targetUser.username()); - updateUser(username, askingUser); + updateUser(extractId(askingUserSearch), askingUser); } Profile targetUserProfile = new Profile(targetUser,true); return targetUserProfile; } - // TODO merge follow and unfollow public Profile unfollowUser(String username, String auth) throws IOException { - SearchResponse getUser = findUserByUsername(username); + SearchResponse targetUserSearch = findUserByUsername(username); + if (targetUserSearch.hits().hits().isEmpty()) { + throw new ResourceNotFoundException("Target user not found"); + } - UserEntity targetUser = extractSource(getUser); + UserEntity targetUser = extractSource(targetUserSearch); // remove followed user to list if not already present - SearchResponse userSearch = getUserEntityFromToken(auth); - UserEntity askingUser = extractSource(userSearch); + SearchResponse askingUserSearch = getUserEntityFromToken(auth); + UserEntity askingUser = extractSource(askingUserSearch); if (askingUser.following().contains(targetUser.username())) { askingUser.following().remove(targetUser.username()); - updateUser(username, askingUser); + updateUser(extractId(askingUserSearch), askingUser); } Profile targetUserProfile = new Profile(targetUser,false); diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Article.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleCreationDAO.java similarity index 77% rename from examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Article.java rename to examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleCreationDAO.java index 6ef2e300d..f51b8e3eb 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Article.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleCreationDAO.java @@ -9,4 +9,4 @@ @JsonTypeName("article") @JsonTypeInfo(include= As.WRAPPER_OBJECT, use= Id.NAME) -public record Article (String title, String description, String body,List tagList){} +public record ArticleCreationDAO(String title, String description, String body, List tagList){} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleEntity.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleEntity.java index ffcba7ef1..db58b37e0 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleEntity.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleEntity.java @@ -19,7 +19,7 @@ public record ArticleEntity( List favoritedBy, Author author) { - public ArticleEntity(Article article, String slug, Instant createdAt, Instant updatedAt, Author author) { + public ArticleEntity(ArticleCreationDAO 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/entity/article/ArticleForListDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleForListDAO.java new file mode 100644 index 000000000..09be10c4e --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleForListDAO.java @@ -0,0 +1,24 @@ +package realworld.entity.article; + +import realworld.entity.user.Author; + +import java.time.Instant; +import java.util.List; + +public record ArticleForListDAO( + String slug, + String title, + String description, + String body, + List tagList, + Instant createdAt, + Instant updatedAt, + boolean favorited, + int favoritesCount, + Author author) { + + + public ArticleForListDAO(ArticleEntity 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/entity/article/Articles.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Articles.java index 3c74495c1..1d058ae28 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Articles.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Articles.java @@ -2,4 +2,4 @@ import java.util.List; -public record Articles (List articles, int articlesCount){} +public record Articles (List articles, int articlesCount){} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Profile.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Profile.java index 3dca0ca52..ace9b8ed9 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Profile.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Profile.java @@ -9,11 +9,11 @@ @JsonTypeInfo(include= As.WRAPPER_OBJECT, use= Id.NAME) public record Profile ( String username, - String email, + String image, String bio, boolean following){ public Profile(UserEntity ue, boolean following) { - this(ue.username(), ue.email(), ue.bio(), following); + this(ue.username(), ue.image(), ue.bio(), following); } } 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 index 04817a56f..9a5e5b915 100644 --- 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 @@ -15,7 +15,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import realworld.db.ArticleService; -import realworld.entity.article.Article; +import realworld.entity.article.ArticleCreationDAO; import realworld.entity.article.ArticleDAO; import realworld.entity.article.ArticleEntity; import realworld.entity.article.ArticleUpdateDAO; @@ -38,7 +38,7 @@ public ArticleController(ArticleService service) { } @PostMapping("/articles") - public ResponseEntity createArticle(@RequestHeader("Authorization") String auth, @RequestBody Article req) throws IOException { + public ResponseEntity createArticle(@RequestHeader("Authorization") String auth, @RequestBody ArticleCreationDAO req) throws IOException { // TODO check null // TODO consider adding validator ArticleEntity res = service.newArticle(req, auth); From 6f3682a3b3a897e60881537eb0e3e48d4201a1ad Mon Sep 17 00:00:00 2001 From: Laura Date: Tue, 9 Jan 2024 18:17:16 +0100 Subject: [PATCH 04/22] WIP finished articles + comments + tags crud. not bug checked though --- .../java/realworld/db/ArticleService.java | 137 ++++++++++++++---- .../java/realworld/db/CommentService.java | 122 ++++++++++++++++ .../main/java/realworld/db/ElasticClient.java | 6 + .../main/java/realworld/db/UserService.java | 90 ++++++------ .../entity/comment/CommentCreationDAO.java | 11 ++ .../realworld/entity/comment/CommentDAO.java | 16 ++ .../entity/comment/CommentEntity.java | 6 + .../entity/comment/CommentForListDAO.java | 11 ++ .../realworld/entity/comment/Comments.java | 5 + .../java/realworld/entity/user/Author.java | 7 - .../realworld/rest/ArticleController.java | 86 ++++++++--- .../realworld/rest/ProfileController.java | 3 +- .../java/realworld/rest/TagsController.java | 9 +- .../java/realworld/rest/UserController.java | 4 +- .../src/main/resources/application.properties | 2 + 15 files changed, 401 insertions(+), 114 deletions(-) create mode 100644 examples/realworld-app/rw-database/src/main/java/realworld/db/CommentService.java create mode 100644 examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentCreationDAO.java create mode 100644 examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentDAO.java create mode 100644 examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentEntity.java create mode 100644 examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentForListDAO.java create mode 100644 examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/Comments.java diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java b/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java index 83131954a..af5739abb 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java @@ -1,10 +1,13 @@ package realworld.db; import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.elasticsearch._types.FieldValue; import co.elastic.clients.elasticsearch._types.SortOrder; import co.elastic.clients.elasticsearch._types.aggregations.Aggregation; import co.elastic.clients.elasticsearch._types.query_dsl.MatchQuery.Builder; import co.elastic.clients.elasticsearch._types.query_dsl.Query; +import co.elastic.clients.elasticsearch._types.query_dsl.TermsQueryField; +import co.elastic.clients.elasticsearch.core.DeleteByQueryResponse; import co.elastic.clients.elasticsearch.core.IndexRequest; import co.elastic.clients.elasticsearch.core.SearchResponse; import co.elastic.clients.elasticsearch.core.UpdateResponse; @@ -25,6 +28,7 @@ import realworld.entity.exception.UnauthorizedException; import realworld.entity.user.Author; import realworld.entity.user.UserDAO; +import realworld.entity.user.UserEntity; import static realworld.utils.Utility.extractId; import static realworld.utils.Utility.extractSource; @@ -72,7 +76,7 @@ public ArticleEntity newArticle(ArticleCreationDAO article, String auth) throws public SearchResponse singleArticleBySlug(String slug) throws IOException { - // using term query to match exactly the slug and check if it already exists + // using term query to match exactly the slug SearchResponse getArticle = esClient.search(ss -> ss .index("articles") .query(q -> q @@ -90,14 +94,6 @@ public ArticleEntity getArticleBySlug(String slug) throws IOException { return extractSource(articleSearch); } - private SearchResponse getArticleEntitySearchResponse(String slug) throws IOException { - SearchResponse articleSearch = singleArticleBySlug(slug); - if (articleSearch.hits().hits().isEmpty()) { - throw new ResourceNotFoundException("Article not found"); - } - return articleSearch; - } - public ArticleDAO updateArticle(ArticleUpdateDAO article, String auth, String slug) throws IOException { // getting original article from slug @@ -133,12 +129,41 @@ public ArticleDAO updateArticle(ArticleUpdateDAO article, String auth, String sl return new ArticleDAO(updatedArticle); } - private String generateAndCheckSlug(String article) throws IOException { - String slug = Slugify.builder().build().slugify(article); - if (!singleArticleBySlug(slug).hits().hits().isEmpty()) { - throw new ResourceAlreadyExistsException("Article slug already exists, please change the title"); + public void deleteArticle(String auth, String slug) throws IOException { + + // getting article from slug + SearchResponse articleSearch = getArticleEntitySearchResponse(slug); + String id = extractId(articleSearch); + ArticleEntity articleEntity = extractSource(articleSearch); + + // checking if author is the same + UserDAO ue = userService.getUserFromToken(auth); + Author author = new Author(ue, false); + + if (!articleEntity.author().username().equals(author.username())) { + throw new UnauthorizedException("Cannot delete article from another author"); } - return slug; + + // the delete query is very similar to the search query + DeleteByQueryResponse deleteArticle = esClient.deleteByQuery(d -> d + .index("articles") + .query(q -> q + .term(t -> t + .field("slug.keyword") + .value(slug)) + )); + if (deleteArticle.deleted() < 1) { + throw new RuntimeException("Failed to delete article"); + } + + // also delete every comment to the article, using a term query that will match all comments with the same articleSlug + DeleteByQueryResponse commentsByArticle = esClient.deleteByQuery(d -> d + .index("comments") + .query(q -> q + .term(t -> t + .field("articleSlug") + .value(slug)) + )); } public ArticleEntity favoriteArticle(String slug, String auth) throws IOException { @@ -189,21 +214,13 @@ public ArticleEntity unfavoriteArticle(String slug, String auth) throws IOExcept return updatedArticle; } - private void updateArticle(String id, ArticleEntity updatedArticle) throws IOException { - UpdateResponse upArticle = esClient.update(up -> up - .index("articles") - .id(id) - .doc(updatedArticle) - , ArticleEntity.class); - if (!upArticle.result().name().equals("Updated")) { - throw new RuntimeException("Article update failed"); - } - } - public Articles getArticles(String tag, String author, String favorited, Integer limit, Integer offset) throws IOException { + // TODO Q in theory term, but can we showcase match? List match = new ArrayList<>(); // since all the parameters for this query are optional, the query must be build conditionally + // using a "match" query instead of a "term" query to allow the use of substrings for search + // for example, filtering for articles with the "cat" tag will also return articles with the "caterpillar" tag if (!isNullOrBlank(tag)) { match.add(new Builder() .field("tagList") @@ -241,12 +258,47 @@ public Articles getArticles(String tag, String author, String favorited, Integer .collect(Collectors.toList()), getArticle.hits().hits().size()); } - // since the API definition doesn't specify the return order of tags, sorting by document count using "_count" - // if alphabetical order is preferred, use "_key" instead - NamedValue sort = new NamedValue<>("_count", SortOrder.Asc); + public Articles articleFeed(String auth) throws IOException { + SearchResponse userSearch = userService.getUserEntityFromToken(auth); + UserEntity userEntity = extractSource(userSearch); + + // preparing authors filter from user data + List authorsFilter = userEntity.following().stream() + .map(FieldValue::of).toList(); + + // a terms query can be used to query for multiple values, like authors. + // the sort options is used afterward to determine which field determines the output order + // note how the nested class "author" is easily accessible with the use of the dot notation + 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))) + , ArticleEntity.class); + + return new Articles(articlesByAuthors.hits().hits() + .stream() + .map(Hit::source) + .map(ArticleForListDAO::new) + .collect(Collectors.toList()), articlesByAuthors.hits().hits().size()); + } + - // using a term aggregation is the simplest way to find every distinct tag for each article public Tags allTags() throws IOException { + + // since the API definition doesn't specify the return order of tags, sorting by document count using "_count" + // if alphabetical order is preferred, use "_key" instead + NamedValue sort = new NamedValue<>("_count", SortOrder.Asc); + + // using a term aggregation is the simplest way to find every distinct tag for each article SearchResponse aggregateTags = esClient.search(s -> s .index("articles") .size(0) // this is to only return aggregation result, and not also search result @@ -281,4 +333,31 @@ public List allArticles() throws IOException { .collect(Collectors.toList()); } + private String generateAndCheckSlug(String title) throws IOException { + String slug = Slugify.builder().build().slugify(title); + if (!singleArticleBySlug(slug).hits().hits().isEmpty()) { + throw new ResourceAlreadyExistsException("Article slug already exists, please change the title"); + } + return slug; + } + + private SearchResponse getArticleEntitySearchResponse(String slug) throws IOException { + SearchResponse articleSearch = singleArticleBySlug(slug); + if (articleSearch.hits().hits().isEmpty()) { + throw new ResourceNotFoundException("Article not found"); + } + return articleSearch; + } + + private void updateArticle(String id, ArticleEntity updatedArticle) throws IOException { + UpdateResponse upArticle = esClient.update(up -> up + .index("articles") + .id(id) + .doc(updatedArticle) + , ArticleEntity.class); + if (!upArticle.result().name().equals("Updated")) { + throw new RuntimeException("Article update failed"); + } + } + } 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..6ff5338a7 --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/db/CommentService.java @@ -0,0 +1,122 @@ +package realworld.db; + +import co.elastic.clients.elasticsearch.ElasticsearchClient; +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.entity.article.ArticleCreationDAO; +import realworld.entity.article.ArticleEntity; +import realworld.entity.comment.CommentCreationDAO; +import realworld.entity.comment.CommentEntity; +import realworld.entity.comment.CommentForListDAO; +import realworld.entity.comment.Comments; +import realworld.entity.exception.ResourceNotFoundException; +import realworld.entity.exception.UnauthorizedException; +import realworld.entity.user.Author; +import realworld.entity.user.UserDAO; +import realworld.entity.user.UserEntity; + +import java.io.IOException; +import java.time.Instant; +import java.util.UUID; +import java.util.stream.Collectors; + +import static realworld.utils.Utility.extractSource; + +@Service +public class CommentService { + + private ElasticsearchClient esClient; + private UserService userService; + private ArticleService articleService; + + @Autowired + public CommentService(ElasticsearchClient esClient, UserService userService, ArticleService articleService) { + this.esClient = esClient; + this.userService = userService; + this.articleService = articleService; + } + + public CommentEntity newComment(CommentCreationDAO comment, String slug, String auth) throws IOException { + + // checking if the article exists + articleService.getArticleBySlug(slug); + + // getting the comment's author + SearchResponse commentUser = userService.getUserEntityFromToken(auth); + // assuming you cannot follow yourself + Author commentAuthor = new Author(extractSource(commentUser), false); + Instant now = Instant.now(); + + // pre-generating id since it's a field in the comment class + Integer commentId = UUID.randomUUID().hashCode(); + CommentEntity commentEntity = new CommentEntity(commentId, now, now, comment.body(), commentAuthor, slug); + + IndexRequest commentReq = IndexRequest.of((id -> id + .index("comments") + .document(commentEntity))); + + esClient.index(commentReq); + + return commentEntity; + } + + public void deleteComment(String commentId, String slug, String auth) throws IOException { + + // getting comment by id + // using term query to match exactly the id + SearchResponse getComment = esClient.search(ss -> ss + .index("comments") + .query(q -> q + .term(t -> t + .field("id") + .value(commentId)) + ) + , CommentEntity.class); + + if (getComment.hits().hits().isEmpty()) { + throw new ResourceNotFoundException("Comment not found"); + } + CommentEntity comment = extractSource(getComment); + + // checking if the comment is from the same author + SearchResponse askingUser = userService.getUserEntityFromToken(auth); + if(!extractSource(askingUser).username().equals(comment.author().username())){ + throw new UnauthorizedException("Cannot delete someone else's comment"); + } + + // checking that the slug matches the one received + if (!extractSource(getComment).articleSlug().equals(slug)) { + throw new RuntimeException("Incorrect article slug"); + } + + // deleting comment by id + DeleteByQueryResponse deleteComment = esClient.deleteByQuery(ss -> ss + .index("comments") + .query(q -> q + .term(t -> t + .field("id") + .value(commentId)) + )); + if (deleteComment.deleted() < 1) { + throw new RuntimeException("Failed to delete article"); + } + } + + public Comments allCommentsByArticle(String slug) throws IOException { + SearchResponse commentsByArticle = esClient.search(d -> d + .index("comments") + .query(q -> q + .term(t -> t + .field("articleSlug") + .value(slug)) + ) + , CommentEntity.class); + + return new Comments(commentsByArticle.hits().hits().stream() + .map(x -> new CommentForListDAO(x.source())) + .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 index 2f1846de8..e8fcfce04 100644 --- 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 @@ -73,6 +73,12 @@ public ElasticsearchClient elasticRestClient() throws IOException { .index("articles")); } + BooleanResponse indexResC = esClient.indices().exists(ex -> ex.index("comments")); //TODO constant + if (!indexResC.value()) { + esClient.indices().create(c -> c + .index("comments")); + } + return esClient; } 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 index 5886c42e5..dd79fdcb5 100644 --- 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 @@ -131,7 +131,7 @@ public UserDAO getUserFromToken(String auth) throws IOException { return new UserDAO(extractSource(getUserEntityFromToken(auth))); } - private SearchResponse getUserEntityFromToken(String auth) throws IOException { + public SearchResponse getUserEntityFromToken(String auth) throws IOException { String token; try { token = auth.split(" ")[1]; @@ -165,7 +165,7 @@ public UserDAO updateUser(String auth, User user) throws IOException { // if the username or email are updated, checking uniqueness if(!isNullOrBlank(user.username())&&!user.username().equals(userEntity.username())){ - SearchResponse newUsernameSearch = findUserByUsername(user.username()); + SearchResponse newUsernameSearch = findUserSearchByUsername(user.username()); if(!newUsernameSearch.hits().hits().isEmpty()){ throw new ResourceAlreadyExistsException("Username already exists"); } @@ -203,12 +203,7 @@ private void updateUser(String id, UserEntity ue) throws IOException { public Profile getUserProfile(String username, String auth) throws IOException { - SearchResponse getUser = findUserByUsername(username); - if (getUser.hits().hits().isEmpty()) { - throw new ResourceNotFoundException("Target user not found"); - } - - UserEntity targetUser = extractSource(getUser); + UserEntity targetUser = findUserByUsername(username); // checking if the user is followed by who's asking SearchResponse askingUser = getUserEntityFromToken(auth); @@ -222,43 +217,18 @@ public Profile getUserProfile(String username, String auth) throws IOException { return targetUserProfile; } - private SearchResponse 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))) - , UserEntity.class); - return getUser; - } - - private SearchResponse 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))) - , UserEntity.class); - return getUser; - } - public Profile followUser(String username, String auth) throws IOException { - SearchResponse targetUserSearch = findUserByUsername(username); - if (targetUserSearch.hits().hits().isEmpty()) { - throw new ResourceNotFoundException("Target user not found"); - } - - UserEntity targetUser = extractSource(targetUserSearch); - // add followed user to list if not already present + UserEntity targetUser = findUserByUsername(username); SearchResponse askingUserSearch = getUserEntityFromToken(auth); UserEntity askingUser = extractSource(askingUserSearch); + 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()); @@ -271,18 +241,12 @@ public Profile followUser(String username, String auth) throws IOException { public Profile unfollowUser(String username, String auth) throws IOException { - SearchResponse targetUserSearch = findUserByUsername(username); - if (targetUserSearch.hits().hits().isEmpty()) { - throw new ResourceNotFoundException("Target user not found"); - } - - UserEntity targetUser = extractSource(targetUserSearch); - - // remove followed user to list if not already present + UserEntity targetUser = findUserByUsername(username); SearchResponse askingUserSearch = getUserEntityFromToken(auth); UserEntity askingUser = extractSource(askingUserSearch); + // remove followed user to list if not already present if (askingUser.following().contains(targetUser.username())) { askingUser.following().remove(targetUser.username()); @@ -292,4 +256,36 @@ public Profile unfollowUser(String username, String auth) throws IOException { return targetUserProfile; } + + private UserEntity findUserByUsername(String username) throws IOException { + SearchResponse getUser = findUserSearchByUsername(username); + if (getUser.hits().hits().isEmpty()) { + throw new ResourceNotFoundException("Target user not found"); + } + return extractSource(getUser); + } + + private SearchResponse findUserSearchByUsername(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))) + , UserEntity.class); + return getUser; + } + + private SearchResponse 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))) + , UserEntity.class); + return getUser; + } } diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentCreationDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentCreationDAO.java new file mode 100644 index 000000000..66f10aba5 --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentCreationDAO.java @@ -0,0 +1,11 @@ +package realworld.entity.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; + +@JsonTypeName("comment") +@JsonTypeInfo(include= As.WRAPPER_OBJECT, use= Id.NAME) +public record CommentCreationDAO(String body) { +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentDAO.java new file mode 100644 index 000000000..7e764438c --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentDAO.java @@ -0,0 +1,16 @@ +package realworld.entity.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 realworld.entity.user.Author; +import java.time.Instant; + +@JsonTypeName("comment") +@JsonTypeInfo(include= As.WRAPPER_OBJECT, use= Id.NAME) +public record CommentDAO(Integer id, Instant createdAt, Instant updatedAt, String body, Author author) { + public CommentDAO(CommentEntity commentEntity) { + this(commentEntity.id(), commentEntity.createdAt(),commentEntity.updatedAt(), commentEntity.body(), commentEntity.author()); + } +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentEntity.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentEntity.java new file mode 100644 index 000000000..4ed0b4107 --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentEntity.java @@ -0,0 +1,6 @@ +package realworld.entity.comment; + +import realworld.entity.user.Author; +import java.time.Instant; + +public record CommentEntity(Integer id, Instant createdAt, Instant updatedAt, String body, Author author, String articleSlug) {} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentForListDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentForListDAO.java new file mode 100644 index 000000000..2b613174f --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentForListDAO.java @@ -0,0 +1,11 @@ +package realworld.entity.comment; + +import realworld.entity.user.Author; + +import java.time.Instant; + +public record CommentForListDAO(Integer id, Instant createdAt, Instant updatedAt, String body, Author author) { + public CommentForListDAO(CommentEntity commentEntity) { + this(commentEntity.id(), commentEntity.createdAt(),commentEntity.updatedAt(), commentEntity.body(), commentEntity.author()); + } +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/Comments.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/Comments.java new file mode 100644 index 000000000..ebdbf5626 --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/Comments.java @@ -0,0 +1,5 @@ +package realworld.entity.comment; + +import java.util.List; + +public record Comments(List comments) {} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Author.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Author.java index 24cbf7768..7c72dc2b7 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Author.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Author.java @@ -1,12 +1,5 @@ package realworld.entity.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("author") -@JsonTypeInfo(include= As.WRAPPER_OBJECT, use= Id.NAME) public record Author( String username, String email, 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 index 9a5e5b915..eabb8f6db 100644 --- 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 @@ -4,6 +4,7 @@ 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.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -15,78 +16,121 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import realworld.db.ArticleService; +import realworld.db.CommentService; import realworld.entity.article.ArticleCreationDAO; import realworld.entity.article.ArticleDAO; import realworld.entity.article.ArticleEntity; import realworld.entity.article.ArticleUpdateDAO; import realworld.entity.article.Articles; +import realworld.entity.comment.CommentCreationDAO; +import realworld.entity.comment.CommentDAO; +import realworld.entity.comment.CommentEntity; +import realworld.entity.comment.Comments; import java.io.IOException; import java.util.List; +@CrossOrigin() @RestController -@RequestMapping() +@RequestMapping("/articles") public class ArticleController { - private ArticleService service; + private ArticleService articleService; + private CommentService commentService; Logger logger = LoggerFactory.getLogger(UserController.class); @Autowired - public ArticleController(ArticleService service) { - this.service = service; + public ArticleController(ArticleService articleService, CommentService commentService) { + this.articleService = articleService; + this.commentService = commentService; } - @PostMapping("/articles") + @PostMapping() public ResponseEntity createArticle(@RequestHeader("Authorization") String auth, @RequestBody ArticleCreationDAO req) throws IOException { // TODO check null // TODO consider adding validator - ArticleEntity res = service.newArticle(req, auth); + ArticleEntity res = articleService.newArticle(req, auth); logger.debug("Created new article: {}", res.slug()); return ResponseEntity.ok(new ArticleDAO(res)); } - @GetMapping("/articles/{slug}") + @GetMapping("/{slug}") public ResponseEntity getArticleBySlug(@PathVariable String slug) throws IOException { - ArticleEntity res = service.getArticleBySlug(slug); - logger.debug("Retrieved article: {}", res.slug()); + ArticleEntity res = articleService.getArticleBySlug(slug); + logger.debug("Retrieved article: {}", slug); return ResponseEntity.ok(new ArticleDAO(res)); } - @GetMapping("/articles") + @GetMapping() public ResponseEntity getArticles(@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) throws IOException { - Articles res = service.getArticles(tag, author, favorited, limit, offset); + Articles res = articleService.getArticles(tag, author, favorited, limit, offset); logger.debug("Returned article list"); return ResponseEntity.ok(res); } - @PostMapping("/articles/{slug}/favorite") + @GetMapping("/feed") + public ResponseEntity getFeed(@RequestHeader("Authorization") String auth) throws IOException { + Articles res = articleService.articleFeed(auth); + logger.debug("Generated feed"); + return ResponseEntity.ok(res); + } + + @PostMapping("/{slug}/favorite") public ResponseEntity favoriteArticle(@RequestHeader("Authorization") String auth, @PathVariable String slug) throws IOException { - ArticleEntity res = service.favoriteArticle(slug, auth); - logger.debug("Set article: {} as favorite", res.slug()); + ArticleEntity res = articleService.favoriteArticle(slug, auth); + logger.debug("Set article: {} as favorite", slug); return ResponseEntity.ok(new ArticleDAO(res)); } - @DeleteMapping("/articles/{slug}/favorite") + @DeleteMapping("/{slug}/favorite") public ResponseEntity unfavoriteArticle(@RequestHeader("Authorization") String auth, @PathVariable String slug) throws IOException { - ArticleEntity res = service.unfavoriteArticle(slug, auth); - logger.debug("Removed article: {} from favorites", res.slug()); + ArticleEntity res = articleService.unfavoriteArticle(slug, auth); + logger.debug("Removed article: {} from favorites", slug); return ResponseEntity.ok(new ArticleDAO(res)); } - @PutMapping("/articles/{slug}") + @PutMapping("/{slug}") public ResponseEntity updateArticle(@RequestHeader("Authorization") String auth, @RequestBody ArticleUpdateDAO req, @PathVariable String slug) throws IOException { - ArticleDAO res = service.updateArticle(req, auth, slug); - logger.debug("Set article: {} as favorite", res.slug()); + ArticleDAO res = articleService.updateArticle(req, auth, slug); + logger.debug("Updated article: {}", slug); + return ResponseEntity.ok(res); + } + + @DeleteMapping("/{slug}") + public ResponseEntity deleteArticle(@RequestHeader("Authorization") String auth, @PathVariable String slug) throws IOException { + articleService.deleteArticle(auth, slug); + logger.debug("Deleted article: {}", slug); + return ResponseEntity.ok().build(); + } + + @PostMapping("/{slug}/comments") + public ResponseEntity commentArticle(@RequestHeader("Authorization") String auth, @PathVariable String slug, @RequestBody CommentCreationDAO comment) throws IOException { + CommentEntity res = commentService.newComment(comment,slug,auth); + logger.debug("Commented article: {}", slug); + return ResponseEntity.ok(new CommentDAO(res)); + } + + @GetMapping("/{slug}/comments") + public ResponseEntity allCommentsByArticle(@RequestHeader("Authorization") String auth, @PathVariable String slug) throws IOException { + Comments res = commentService.allCommentsByArticle(slug); + logger.debug("Commented article: {}", slug); return ResponseEntity.ok(res); } + @DeleteMapping("/{slug}/comments/{commentId}") + public ResponseEntity deleteComment(@RequestHeader("Authorization") String auth, @PathVariable String slug, @PathVariable String commentId) throws IOException { + commentService.deleteComment(commentId,slug,auth); + logger.debug("Deleted comment: {} from article {}", commentId, slug); + return ResponseEntity.ok().build(); + } + //TODO REMOVE, JUST FOR TEST @GetMapping("/allarticles") public ResponseEntity> getAllArticlesTest() throws IOException { - List res = service.allArticles(); + List res = articleService.allArticles(); logger.debug("Returned article list"); return ResponseEntity.ok(res); } 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 index 9cb99266c..31659e661 100644 --- 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 @@ -4,6 +4,7 @@ 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.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -16,7 +17,7 @@ import java.io.IOException; - +@CrossOrigin() @RestController @RequestMapping("/profiles") public class ProfileController { 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 index bf84f4e0e..1d5d30701 100644 --- 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 @@ -4,21 +4,16 @@ import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import realworld.db.ArticleService; -import realworld.db.UserService; import realworld.entity.article.Tags; -import realworld.entity.user.Profile; import java.io.IOException; - +@CrossOrigin() @RestController @RequestMapping("/tags") public class TagsController { 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 index 879d12067..100b8878f 100644 --- 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 @@ -5,6 +5,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; 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.PostMapping; import org.springframework.web.bind.annotation.PutMapping; @@ -17,11 +18,10 @@ import realworld.entity.user.UserDAO; import java.io.IOException; -import java.util.Objects; import static realworld.utils.Utility.isNullOrBlank; - +@CrossOrigin() @RestController @RequestMapping() public class UserController { diff --git a/examples/realworld-app/rw-server/src/main/resources/application.properties b/examples/realworld-app/rw-server/src/main/resources/application.properties index cb2b098b6..990c978aa 100644 --- a/examples/realworld-app/rw-server/src/main/resources/application.properties +++ b/examples/realworld-app/rw-server/src/main/resources/application.properties @@ -3,8 +3,10 @@ ### server.port=8080 server.address=localhost +logging.level.org.springframework.web=DEBUG ### # Elasticsearch Settings ### elasticsearch.server.url=http://localhost:9200 elasticsearch.api.key=VnVhQ2ZHY0JDZGJrU... + From 59005cea9c8130d27e48ed55891fef5bcb9e4622 Mon Sep 17 00:00:00 2001 From: Laura Date: Wed, 10 Jan 2024 15:25:07 +0100 Subject: [PATCH 05/22] minimal bugfix, added waitFor and refresh to post and delete methods --- .../main/java/realworld/db/ArticleService.java | 17 ++++++++++++++--- .../main/java/realworld/db/CommentService.java | 11 ++++++----- .../src/main/java/realworld/db/UserService.java | 4 +++- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java b/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java index af5739abb..0bf7e1dd9 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java @@ -2,6 +2,7 @@ import co.elastic.clients.elasticsearch.ElasticsearchClient; import co.elastic.clients.elasticsearch._types.FieldValue; +import co.elastic.clients.elasticsearch._types.Refresh; import co.elastic.clients.elasticsearch._types.SortOrder; import co.elastic.clients.elasticsearch._types.aggregations.Aggregation; import co.elastic.clients.elasticsearch._types.query_dsl.MatchQuery.Builder; @@ -67,6 +68,7 @@ public ArticleEntity newArticle(ArticleCreationDAO article, String auth) throws IndexRequest articleReq = IndexRequest.of((id -> id .index("articles") + .refresh(Refresh.WaitFor) .document(articleEntity))); esClient.index(articleReq); @@ -111,7 +113,7 @@ public ArticleDAO updateArticle(ArticleUpdateDAO article, String auth, String sl String newSlug = slug; // if title is being changed, checking if new slug would be unique - if (!isNullOrBlank(article.title())) { + if (!isNullOrBlank(article.title())&&!article.title().equals(oldArticle.title())) { newSlug = generateAndCheckSlug(article.title()); } @@ -147,6 +149,8 @@ public void deleteArticle(String auth, String slug) throws IOException { // the delete query is very similar to the search query DeleteByQueryResponse deleteArticle = esClient.deleteByQuery(d -> d .index("articles") + .waitForCompletion(true) + .refresh(true) .query(q -> q .term(t -> t .field("slug.keyword") @@ -159,6 +163,8 @@ public void deleteArticle(String auth, String slug) throws IOException { // also delete every comment to the article, using a term query that will match all comments with the same articleSlug DeleteByQueryResponse commentsByArticle = esClient.deleteByQuery(d -> d .index("comments") + .waitForCompletion(true) + .refresh(true) .query(q -> q .term(t -> t .field("articleSlug") @@ -243,7 +249,12 @@ public Articles getArticles(String tag, String author, String favorited, Integer .index("articles") .size(limit) .from(offset) - .query(query), ArticleEntity.class); + .query(query) + .sort(srt -> srt + .field(fld -> fld + .field("updatedAt") + .order(SortOrder.Desc))) + , ArticleEntity.class); return new Articles(getArticle.hits().hits() .stream() @@ -296,7 +307,7 @@ public Tags allTags() throws IOException { // since the API definition doesn't specify the return order of tags, sorting by document count using "_count" // if alphabetical order is preferred, use "_key" instead - NamedValue sort = new NamedValue<>("_count", SortOrder.Asc); + NamedValue sort = new NamedValue<>("_count", SortOrder.Desc); // using a term aggregation is the simplest way to find every distinct tag for each article SearchResponse aggregateTags = esClient.search(s -> s 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 index 6ff5338a7..3b2bf8b57 100644 --- 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 @@ -1,13 +1,12 @@ 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.entity.article.ArticleCreationDAO; -import realworld.entity.article.ArticleEntity; import realworld.entity.comment.CommentCreationDAO; import realworld.entity.comment.CommentEntity; import realworld.entity.comment.CommentForListDAO; @@ -15,7 +14,6 @@ import realworld.entity.exception.ResourceNotFoundException; import realworld.entity.exception.UnauthorizedException; import realworld.entity.user.Author; -import realworld.entity.user.UserDAO; import realworld.entity.user.UserEntity; import java.io.IOException; @@ -56,6 +54,7 @@ public CommentEntity newComment(CommentCreationDAO comment, String slug, String IndexRequest commentReq = IndexRequest.of((id -> id .index("comments") + .refresh(Refresh.WaitFor) .document(commentEntity))); esClient.index(commentReq); @@ -95,6 +94,8 @@ public void deleteComment(String commentId, String slug, String auth) throws IOE // deleting comment by id DeleteByQueryResponse deleteComment = esClient.deleteByQuery(ss -> ss .index("comments") + .waitForCompletion(true) + .refresh(true) .query(q -> q .term(t -> t .field("id") @@ -106,11 +107,11 @@ public void deleteComment(String commentId, String slug, String auth) throws IOE } public Comments allCommentsByArticle(String slug) throws IOException { - SearchResponse commentsByArticle = esClient.search(d -> d + SearchResponse commentsByArticle = esClient.search(s -> s .index("comments") .query(q -> q .term(t -> t - .field("articleSlug") + .field("articleSlug.keyword") .value(slug)) ) , CommentEntity.class); 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 index dd79fdcb5..5c835c933 100644 --- 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 @@ -1,6 +1,7 @@ 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; @@ -43,7 +44,7 @@ public UserDAO newUser(UserDAO user) throws IOException { // (a "match" query would also find words containing the value inserted) // using "should" to find documents matching either condition. - // TODO explain keyword + // TODO explain keyword -> no tokenizer, stored as is. not important here since everything is a single word SearchResponse checkUser = esClient.search(ss -> ss .index("users") @@ -94,6 +95,7 @@ public UserDAO newUser(UserDAO user) throws IOException { IndexRequest userReq = IndexRequest.of((id -> id .index("users") + .refresh(Refresh.WaitFor) .document(ue))); esClient.index(userReq); From daeb9b55e907a97076a575d8aeb172a7925a9aaf Mon Sep 17 00:00:00 2001 From: Laura Date: Thu, 11 Jan 2024 14:09:05 +0100 Subject: [PATCH 06/22] minor refactor, WIP documentation, added license --- .../java/realworld/constant/Constants.java | 27 ++++++ .../java/realworld/db/ArticleService.java | 38 +++++++-- .../java/realworld/db/CommentService.java | 19 +++++ .../main/java/realworld/db/ElasticClient.java | 83 ++++++++++++++----- .../main/java/realworld/db/UserService.java | 34 +++++++- .../entity/article/ArticleCreationDAO.java | 19 +++++ .../realworld/entity/article/ArticleDAO.java | 19 +++++ .../entity/article/ArticleEntity.java | 19 +++++ .../entity/article/ArticleForListDAO.java | 19 +++++ .../entity/article/ArticleUpdateDAO.java | 19 +++++ .../realworld/entity/article/Articles.java | 19 +++++ .../java/realworld/entity/article/Tags.java | 19 +++++ .../entity/comment/CommentCreationDAO.java | 19 +++++ .../realworld/entity/comment/CommentDAO.java | 20 +++++ .../entity/comment/CommentEntity.java | 20 +++++ .../entity/comment/CommentForListDAO.java | 19 +++++ .../realworld/entity/comment/Comments.java | 19 +++++ .../ResourceAlreadyExistsException.java | 19 +++++ .../exception/ResourceNotFoundException.java | 19 +++++ .../exception/UnauthorizedException.java | 19 +++++ .../java/realworld/entity/user/Author.java | 19 +++++ .../java/realworld/entity/user/LoginDAO.java | 19 +++++ .../java/realworld/entity/user/Profile.java | 19 +++++ .../realworld/entity/user/RegisterDAO.java | 19 +++++ .../main/java/realworld/entity/user/User.java | 19 +++++ .../java/realworld/entity/user/UserDAO.java | 19 +++++ .../realworld/entity/user/UserEntity.java | 19 +++++ .../main/java/realworld/utils/Utility.java | 20 ++++- .../realworld/rest/ArticleController.java | 19 +++++ .../realworld/rest/ProfileController.java | 19 +++++ .../java/realworld/rest/TagsController.java | 19 +++++ .../java/realworld/rest/UserController.java | 19 +++++ .../java/realworld/rest/error/RestError.java | 19 +++++ .../rest/error/RestExceptionHandler.java | 19 +++++ .../main/java/realworld/SpringBootApp.java | 19 +++++ .../realworld/config/DefaultProperties.java | 19 +++++ .../src/main/resources/application.properties | 1 + 37 files changed, 765 insertions(+), 29 deletions(-) create mode 100644 examples/realworld-app/rw-database/src/main/java/realworld/constant/Constants.java diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/constant/Constants.java b/examples/realworld-app/rw-database/src/main/java/realworld/constant/Constants.java new file mode 100644 index 000000000..7149f39ad --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/constant/Constants.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.constant; + +public class Constants { + + public static final String ARTICLES = "articles"; + public static final String USERS = "users"; + public static final String COMMENTS = "comments"; +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java b/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java index 0bf7e1dd9..c7b468246 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java @@ -1,3 +1,22 @@ +/* + * 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; @@ -31,10 +50,6 @@ import realworld.entity.user.UserDAO; import realworld.entity.user.UserEntity; -import static realworld.utils.Utility.extractId; -import static realworld.utils.Utility.extractSource; -import static realworld.utils.Utility.isNullOrBlank; - import java.io.IOException; import java.time.Instant; import java.util.ArrayList; @@ -42,6 +57,10 @@ import java.util.List; import java.util.stream.Collectors; +import static realworld.utils.Utility.extractId; +import static realworld.utils.Utility.extractSource; +import static realworld.utils.Utility.isNullOrBlank; + @Service public class ArticleService { @@ -54,12 +73,19 @@ public ArticleService(ElasticsearchClient esClient, UserService userService) { this.userService = userService; } + /** + * Creates a new article and saves it into the articles index. + * @param article + * @param auth + * @return {@link realworld.entity.article.ArticleEntity} + * @throws IOException + */ public ArticleEntity newArticle(ArticleCreationDAO article, String auth) throws IOException { - // checking if slug would be unique + // Checking if slug would be unique String slug = generateAndCheckSlug(article.title()); - // getting the author + // Getting the author UserDAO ue = userService.getUserFromToken(auth); Author author = new Author(ue, false); Instant now = Instant.now(); 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 index 3b2bf8b57..63323c755 100644 --- 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 @@ -1,3 +1,22 @@ +/* + * 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; 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 index e8fcfce04..aeeab629a 100644 --- 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 @@ -1,3 +1,22 @@ +/* + * 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; @@ -18,6 +37,10 @@ import java.io.IOException; +import static realworld.constant.Constants.ARTICLES; +import static realworld.constant.Constants.COMMENTS; +import static realworld.constant.Constants.USERS; + @Configuration public class ElasticClient { @@ -27,6 +50,11 @@ public class ElasticClient { @Value("${elasticsearch.api.key}") private String apiKey; + /** + * Creates the ElasticsearchClient and the indexes needed + * @return a configured ElasticsearchClient + * @throws IOException + */ @Bean public ElasticsearchClient elasticRestClient() throws IOException { @@ -38,9 +66,8 @@ public ElasticsearchClient elasticRestClient() throws IOException { }) .build(); - ObjectMapper mapper = JsonMapper.builder() // or different mapper for other format - .addModule(new JavaTimeModule()) - // and possibly other configuration, modules, then: + ObjectMapper mapper = JsonMapper.builder() + .addModule(new JavaTimeModule()) // other modules can be added here .build(); // Create the transport with a Jackson mapper @@ -50,8 +77,6 @@ public ElasticsearchClient elasticRestClient() throws IOException { // And create the API client ElasticsearchClient esClient = new ElasticsearchClient(transport); - // check and/or create indexes - // TODO remove, for testing // esClient.indices().delete(del -> del // .ignoreUnavailable(true) @@ -61,25 +86,45 @@ public ElasticsearchClient elasticRestClient() throws IOException { // .ignoreUnavailable(true) // .index("articles")); - BooleanResponse indexResU = esClient.indices().exists(ex -> ex.index("users")); //TODO constant - if (!indexResU.value()) { - esClient.indices().create(c -> c - .index("users")); - } + // Creating the indexes + createSimpleIndex(esClient, USERS); + createIndexWithDateMapping(esClient, ARTICLES); + createIndexWithDateMapping(esClient, COMMENTS); - BooleanResponse indexResA = esClient.indices().exists(ex -> ex.index("articles")); //TODO constant - if (!indexResA.value()) { - esClient.indices().create(c -> c - .index("articles")); - } + return esClient; + + } - BooleanResponse indexResC = esClient.indices().exists(ex -> ex.index("comments")); //TODO constant - if (!indexResC.value()) { + /** + * 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("comments")); + .index(index)); } + } - return esClient; + /** + * 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("epoch_millis"))) + .properties("updatedAt", p -> p + .date(d -> d.format("epoch_millis"))))); + } } } 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 index 5c835c933..b60cf876e 100644 --- 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 @@ -1,3 +1,22 @@ +/* + * 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; @@ -10,6 +29,7 @@ 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.entity.exception.ResourceAlreadyExistsException; import realworld.entity.exception.ResourceNotFoundException; @@ -32,6 +52,9 @@ public class UserService { private ElasticsearchClient esClient; + @Value("jwt.signing.key") + private String jwtSigningKey; + @Autowired public UserService(ElasticsearchClient esClient) { this.esClient = esClient; @@ -83,10 +106,9 @@ public UserDAO newUser(UserDAO user) throws IOException { .claim("name", user.username()) .claim("scope", "user") .setIssuedAt(Date.from(Instant.now())) - //.setExpiration(Date.from(Instant.now().plus(5, ChronoUnit.MINUTES))) .signWith( SignatureAlgorithm.HS256, - TextCodec.BASE64.decode("c3VjaGFteXN0ZXJ5b3Vyc3VwZXJzZWNyZXR3b3c=") + TextCodec.BASE64.decode(jwtSigningKey) ) .compact(); @@ -133,12 +155,18 @@ public UserDAO getUserFromToken(String auth) throws IOException { return new UserDAO(extractSource(getUserEntityFromToken(auth))); } + /** + * + * @param auth + * @return + * @throws IOException + */ public SearchResponse getUserEntityFromToken(String auth) throws IOException { String token; try { token = auth.split(" ")[1]; Jwts.parser() - .setSigningKey(TextCodec.BASE64.decode("c3VjaGFteXN0ZXJ5b3Vyc3VwZXJzZWNyZXR3b3c=")) + .setSigningKey(TextCodec.BASE64.decode(jwtSigningKey)) .parse(token); } catch (Exception e) { throw new RuntimeException("Token not recognised",e); diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleCreationDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleCreationDAO.java index f51b8e3eb..7b5ae2046 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleCreationDAO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleCreationDAO.java @@ -1,3 +1,22 @@ +/* + * 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.entity.article; import com.fasterxml.jackson.annotation.JsonTypeInfo; diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleDAO.java index 4b7664b33..4dc41923e 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleDAO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleDAO.java @@ -1,3 +1,22 @@ +/* + * 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.entity.article; import com.fasterxml.jackson.annotation.JsonTypeInfo; diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleEntity.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleEntity.java index db58b37e0..57be78bc7 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleEntity.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleEntity.java @@ -1,3 +1,22 @@ +/* + * 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.entity.article; import realworld.entity.user.Author; diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleForListDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleForListDAO.java index 09be10c4e..60eb97615 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleForListDAO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleForListDAO.java @@ -1,3 +1,22 @@ +/* + * 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.entity.article; import realworld.entity.user.Author; diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleUpdateDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleUpdateDAO.java index 96e815e2f..0f5d9fb22 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleUpdateDAO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleUpdateDAO.java @@ -1,3 +1,22 @@ +/* + * 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.entity.article; import com.fasterxml.jackson.annotation.JsonTypeInfo; diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Articles.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Articles.java index 1d058ae28..affe18f30 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Articles.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Articles.java @@ -1,3 +1,22 @@ +/* + * 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.entity.article; import java.util.List; diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Tags.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Tags.java index ef7e69726..b9ff91f91 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Tags.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Tags.java @@ -1,3 +1,22 @@ +/* + * 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.entity.article; import java.util.List; diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentCreationDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentCreationDAO.java index 66f10aba5..de1db6a36 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentCreationDAO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentCreationDAO.java @@ -1,3 +1,22 @@ +/* + * 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.entity.comment; import com.fasterxml.jackson.annotation.JsonTypeInfo; diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentDAO.java index 7e764438c..be7e8c8f0 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentDAO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentDAO.java @@ -1,3 +1,22 @@ +/* + * 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.entity.comment; import com.fasterxml.jackson.annotation.JsonTypeInfo; @@ -5,6 +24,7 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo.Id; import com.fasterxml.jackson.annotation.JsonTypeName; import realworld.entity.user.Author; + import java.time.Instant; @JsonTypeName("comment") diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentEntity.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentEntity.java index 4ed0b4107..cc6f03ef0 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentEntity.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentEntity.java @@ -1,6 +1,26 @@ +/* + * 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.entity.comment; import realworld.entity.user.Author; + import java.time.Instant; public record CommentEntity(Integer id, Instant createdAt, Instant updatedAt, String body, Author author, String articleSlug) {} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentForListDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentForListDAO.java index 2b613174f..cd010e8c3 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentForListDAO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentForListDAO.java @@ -1,3 +1,22 @@ +/* + * 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.entity.comment; import realworld.entity.user.Author; diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/Comments.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/Comments.java index ebdbf5626..338b9f618 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/Comments.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/Comments.java @@ -1,3 +1,22 @@ +/* + * 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.entity.comment; import java.util.List; diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/exception/ResourceAlreadyExistsException.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/exception/ResourceAlreadyExistsException.java index 65c397c41..7b095b06f 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/exception/ResourceAlreadyExistsException.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/exception/ResourceAlreadyExistsException.java @@ -1,3 +1,22 @@ +/* + * 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.entity.exception; public class ResourceAlreadyExistsException extends RuntimeException{ diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/exception/ResourceNotFoundException.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/exception/ResourceNotFoundException.java index ef32eda66..8f8089540 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/exception/ResourceNotFoundException.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/exception/ResourceNotFoundException.java @@ -1,3 +1,22 @@ +/* + * 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.entity.exception; public class ResourceNotFoundException extends RuntimeException{ diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/exception/UnauthorizedException.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/exception/UnauthorizedException.java index 746aa5319..78301a742 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/exception/UnauthorizedException.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/exception/UnauthorizedException.java @@ -1,3 +1,22 @@ +/* + * 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.entity.exception; public class UnauthorizedException extends RuntimeException{ diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Author.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Author.java index 7c72dc2b7..cf999c5cd 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Author.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Author.java @@ -1,3 +1,22 @@ +/* + * 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.entity.user; public record Author( diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/LoginDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/LoginDAO.java index 5dac8e870..85387e20c 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/LoginDAO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/LoginDAO.java @@ -1,3 +1,22 @@ +/* + * 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.entity.user; public class LoginDAO { diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Profile.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Profile.java index ace9b8ed9..002a3fd9b 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Profile.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Profile.java @@ -1,3 +1,22 @@ +/* + * 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.entity.user; import com.fasterxml.jackson.annotation.JsonTypeInfo; diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/RegisterDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/RegisterDAO.java index 8fd76d31b..aa097a7ad 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/RegisterDAO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/RegisterDAO.java @@ -1,3 +1,22 @@ +/* + * 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.entity.user; public class RegisterDAO { diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/User.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/User.java index e265c1ed6..7442c05bd 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/User.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/User.java @@ -1,3 +1,22 @@ +/* + * 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.entity.user; import com.fasterxml.jackson.annotation.JsonTypeInfo; diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserDAO.java index 65552456a..d03ebe85f 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserDAO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserDAO.java @@ -1,3 +1,22 @@ +/* + * 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.entity.user; import com.fasterxml.jackson.annotation.JsonTypeInfo; diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserEntity.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserEntity.java index 96cfc70c0..dc427f3d0 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserEntity.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserEntity.java @@ -1,3 +1,22 @@ +/* + * 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.entity.user; import java.util.List; 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 index 4429c7bfd..40a2f2ca4 100644 --- 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 @@ -1,7 +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 co.elastic.clients.elasticsearch.core.SearchResponse; -import realworld.entity.user.UserEntity; import java.util.Objects; 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 index eabb8f6db..668a17f42 100644 --- 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 @@ -1,3 +1,22 @@ +/* + * 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; 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 index 31659e661..be9739847 100644 --- 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 @@ -1,3 +1,22 @@ +/* + * 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; 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 index 1d5d30701..d028d127d 100644 --- 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 @@ -1,3 +1,22 @@ +/* + * 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; 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 index 100b8878f..b02fdb9f9 100644 --- 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 @@ -1,3 +1,22 @@ +/* + * 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; 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 index d39f33ee0..3d88a7aaf 100644 --- 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 @@ -1,3 +1,22 @@ +/* + * 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; 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 index e0edb70ab..504eb01bf 100644 --- 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 @@ -1,3 +1,22 @@ +/* + * 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; diff --git a/examples/realworld-app/rw-server/src/main/java/realworld/SpringBootApp.java b/examples/realworld-app/rw-server/src/main/java/realworld/SpringBootApp.java index 2d98e80bd..0c3570310 100644 --- a/examples/realworld-app/rw-server/src/main/java/realworld/SpringBootApp.java +++ b/examples/realworld-app/rw-server/src/main/java/realworld/SpringBootApp.java @@ -1,3 +1,22 @@ +/* + * 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; import org.springframework.boot.SpringApplication; diff --git a/examples/realworld-app/rw-server/src/main/java/realworld/config/DefaultProperties.java b/examples/realworld-app/rw-server/src/main/java/realworld/config/DefaultProperties.java index 644610276..c0e90bb4f 100644 --- a/examples/realworld-app/rw-server/src/main/java/realworld/config/DefaultProperties.java +++ b/examples/realworld-app/rw-server/src/main/java/realworld/config/DefaultProperties.java @@ -1,3 +1,22 @@ +/* + * 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.config; import java.util.Properties; diff --git a/examples/realworld-app/rw-server/src/main/resources/application.properties b/examples/realworld-app/rw-server/src/main/resources/application.properties index 990c978aa..c4b6a564c 100644 --- a/examples/realworld-app/rw-server/src/main/resources/application.properties +++ b/examples/realworld-app/rw-server/src/main/resources/application.properties @@ -4,6 +4,7 @@ server.port=8080 server.address=localhost logging.level.org.springframework.web=DEBUG +jwt.signing.key=c3VjaGFteXN0ZXJ5b3Vyc3VwZXJzZWNyZXR3b3c= ### # Elasticsearch Settings ### From 2b7fbbeb497ca85242b3bc23fc16a48f6093fc08 Mon Sep 17 00:00:00 2001 From: Laura Date: Thu, 11 Jan 2024 18:23:45 +0100 Subject: [PATCH 07/22] major refactor, added validation to user controller, WIP testcontainer unit test example --- .../realworld-app/rw-database/build.gradle | 10 +++ .../java/realworld/db/ArticleService.java | 61 ++++++++--------- .../java/realworld/db/CommentService.java | 10 +-- .../main/java/realworld/db/ElasticClient.java | 8 ++- .../main/java/realworld/db/UserService.java | 39 ++++++----- .../realworld/entity/article/ArticleDAO.java | 3 +- .../entity/article/ArticleEntity.java | 7 +- .../entity/article/ArticleForListDAO.java | 7 +- .../realworld/entity/comment/CommentDAO.java | 2 +- .../entity/comment/CommentEntity.java | 4 +- .../entity/comment/CommentForListDAO.java | 2 +- .../java/realworld/entity/user/LoginDAO.java | 4 +- .../realworld/entity/user/RegisterDAO.java | 4 +- .../main/java/realworld/entity/user/User.java | 41 ------------ .../java/realworld/entity/user/UserDAO.java | 3 +- .../java/realworld/db/UserServiceTest.java | 66 +++++++++++++++++++ .../src/test/resources/test.properties | 9 +++ .../realworld/rest/ArticleController.java | 13 +--- .../java/realworld/rest/UserController.java | 44 +++++-------- .../rest/error/RestExceptionHandler.java | 17 +++++ 20 files changed, 203 insertions(+), 151 deletions(-) delete mode 100644 examples/realworld-app/rw-database/src/main/java/realworld/entity/user/User.java create mode 100644 examples/realworld-app/rw-database/src/test/java/realworld/db/UserServiceTest.java create mode 100644 examples/realworld-app/rw-database/src/test/resources/test.properties diff --git a/examples/realworld-app/rw-database/build.gradle b/examples/realworld-app/rw-database/build.gradle index 55474fa92..a41ddb5f1 100644 --- a/examples/realworld-app/rw-database/build.gradle +++ b/examples/realworld-app/rw-database/build.gradle @@ -17,12 +17,22 @@ repositories { //TODO uniform imports dependencies { implementation 'org.springframework.boot:spring-boot-starter:3.2.0' + implementation("org.springframework.boot:spring-boot-starter-validation:3.2.0") + implementation("co.elastic.clients:elasticsearch-java:8.11.2") implementation("com.fasterxml.jackson.core:jackson-databind:2.12.3") implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.16.0") + implementation ("io.jsonwebtoken:jjwt:0.9.1") implementation("javax.xml.bind:jaxb-api:2.3.1") implementation 'com.github.slugify:slugify:3.0.6' + + // MIT + // https://www.testcontainers.org/ + testImplementation("org.testcontainers:testcontainers:1.17.3") + testImplementation("org.testcontainers:elasticsearch:1.17.3") + testImplementation("org.springframework.boot:spring-boot-starter-test:3.2.0") + } tasks.named('test') { diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java b/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java index c7b468246..bfb5e0a08 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java @@ -55,6 +55,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Objects; import java.util.stream.Collectors; import static realworld.utils.Utility.extractId; @@ -77,7 +78,7 @@ public ArticleService(ElasticsearchClient esClient, UserService userService) { * Creates a new article and saves it into the articles index. * @param article * @param auth - * @return {@link realworld.entity.article.ArticleEntity} + * @return {@link ArticleEntity} * @throws IOException */ public ArticleEntity newArticle(ArticleCreationDAO article, String auth) throws IOException { @@ -86,9 +87,9 @@ public ArticleEntity newArticle(ArticleCreationDAO article, String auth) throws String slug = generateAndCheckSlug(article.title()); // Getting the author - UserDAO ue = userService.getUserFromToken(auth); + UserEntity ue = userService.getUserEntityFromToken(auth); Author author = new Author(ue, false); - Instant now = Instant.now(); + Long now = Instant.now().toEpochMilli(); ArticleEntity articleEntity = new ArticleEntity(article, slug, now, now, author); @@ -130,7 +131,7 @@ public ArticleDAO updateArticle(ArticleUpdateDAO article, String auth, String sl ArticleEntity oldArticle = extractSource(articleSearch); // checking if author is the same - UserDAO ue = userService.getUserFromToken(auth); + UserEntity ue = userService.getUserEntityFromToken(auth); Author author = new Author(ue, false); if (!oldArticle.author().username().equals(author.username())) { @@ -143,7 +144,7 @@ public ArticleDAO updateArticle(ArticleUpdateDAO article, String auth, String sl newSlug = generateAndCheckSlug(article.title()); } - Instant updatedAt = Instant.now(); + Long updatedAt = Instant.now().toEpochMilli(); ArticleEntity updatedArticle = new ArticleEntity(newSlug, isNullOrBlank(article.title()) ? oldArticle.title() : article.title(), @@ -161,11 +162,10 @@ public void deleteArticle(String auth, String slug) throws IOException { // getting article from slug SearchResponse articleSearch = getArticleEntitySearchResponse(slug); - String id = extractId(articleSearch); ArticleEntity articleEntity = extractSource(articleSearch); // checking if author is the same - UserDAO ue = userService.getUserFromToken(auth); + UserEntity ue = userService.getUserEntityFromToken(auth); Author author = new Author(ue, false); if (!articleEntity.author().username().equals(author.username())) { @@ -193,13 +193,13 @@ public void deleteArticle(String auth, String slug) throws IOException { .refresh(true) .query(q -> q .term(t -> t - .field("articleSlug") + .field("articleSlug.keyword") .value(slug)) )); } public ArticleEntity favoriteArticle(String slug, String auth) throws IOException { - UserDAO user = userService.getUserFromToken(auth); + UserEntity user = userService.getUserEntityFromToken(auth); SearchResponse articleSearch = getArticleEntitySearchResponse(slug); String id = extractId(articleSearch); @@ -220,7 +220,7 @@ public ArticleEntity favoriteArticle(String slug, String auth) throws IOExceptio } public ArticleEntity unfavoriteArticle(String slug, String auth) throws IOException { - UserDAO user = userService.getUserFromToken(auth); + UserEntity user = userService.getUserEntityFromToken(auth); SearchResponse articleSearch = getArticleEntitySearchResponse(slug); String id = extractId(articleSearch); @@ -246,13 +246,15 @@ public ArticleEntity unfavoriteArticle(String slug, String auth) throws IOExcept return updatedArticle; } - public Articles getArticles(String tag, String author, String favorited, Integer limit, Integer offset) throws IOException { - - // TODO Q in theory term, but can we showcase match? + public Articles getArticles(String tag, String author, String favorited, Integer limit, Integer offset, String auth) throws IOException { + UserEntity user = null; + if(!isNullOrBlank(auth)){ + user = userService.getUserEntityFromToken(auth); + } List match = new ArrayList<>(); // since all the parameters for this query are optional, the query must be build conditionally - // using a "match" query instead of a "term" query to allow the use of substrings for search - // for example, filtering for articles with the "cat" tag will also return articles with the "caterpillar" tag + // using a "match" query instead of a "term" query to allow the use a single word for searching phrases + // for example, filtering for articles with the "cat" tag will also return articles with the "cat food" tag if (!isNullOrBlank(tag)) { match.add(new Builder() .field("tagList") @@ -260,7 +262,7 @@ public Articles getArticles(String tag, String author, String favorited, Integer } if (!isNullOrBlank(author)) { match.add(new Builder() - .field("author") + .field("author.username") .query(author).build()._toQuery()); } if (!isNullOrBlank(favorited)) { @@ -282,6 +284,7 @@ public Articles getArticles(String tag, String author, String favorited, Integer .order(SortOrder.Desc))) , ArticleEntity.class); + UserEntity finalUser = user; return new Articles(getArticle.hits().hits() .stream() .map(Hit::source) @@ -292,12 +295,19 @@ public Articles getArticles(String tag, String author, String favorited, Integer } }) .map(ArticleForListDAO::new) + // if auth provided, filling the "following" field of "Author" accordingly + .map(a -> { + if(Objects.nonNull(finalUser)){ + boolean following = finalUser.following().contains(a.author().username()); + return new ArticleForListDAO(a, new Author(a.author().username(), a.author().email(), a.author().bio(), following)); + } + return a; + }) .collect(Collectors.toList()), getArticle.hits().hits().size()); } public Articles articleFeed(String auth) throws IOException { - SearchResponse userSearch = userService.getUserEntityFromToken(auth); - UserEntity userEntity = extractSource(userSearch); + UserEntity userEntity = userService.getUserEntityFromToken(auth); // preparing authors filter from user data List authorsFilter = userEntity.following().stream() @@ -355,21 +365,6 @@ public Tags allTags() throws IOException { ); } - // TODO remove - public List allArticles() throws IOException { - SearchResponse getArticle = esClient.search(ss -> ss - .index("articles") - .query(q -> q - .matchAll(m -> m) - ) - , ArticleEntity.class); - - return getArticle.hits().hits() - .stream() - .map(Hit::source) - .collect(Collectors.toList()); - } - private String generateAndCheckSlug(String title) throws IOException { String slug = Slugify.builder().build().slugify(title); if (!singleArticleBySlug(slug).hits().hits().isEmpty()) { 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 index 63323c755..7c30ec40f 100644 --- 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 @@ -62,10 +62,10 @@ public CommentEntity newComment(CommentCreationDAO comment, String slug, String articleService.getArticleBySlug(slug); // getting the comment's author - SearchResponse commentUser = userService.getUserEntityFromToken(auth); + UserEntity commentUser = userService.getUserEntityFromToken(auth); // assuming you cannot follow yourself - Author commentAuthor = new Author(extractSource(commentUser), false); - Instant now = Instant.now(); + Author commentAuthor = new Author(commentUser, false); + Long now = Instant.now().toEpochMilli(); // pre-generating id since it's a field in the comment class Integer commentId = UUID.randomUUID().hashCode(); @@ -100,8 +100,8 @@ public void deleteComment(String commentId, String slug, String auth) throws IOE CommentEntity comment = extractSource(getComment); // checking if the comment is from the same author - SearchResponse askingUser = userService.getUserEntityFromToken(auth); - if(!extractSource(askingUser).username().equals(comment.author().username())){ + UserEntity askingUser = userService.getUserEntityFromToken(auth); + if(!askingUser.username().equals(comment.author().username())){ throw new UnauthorizedException("Cannot delete someone else's comment"); } 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 index aeeab629a..57a96ca5e 100644 --- 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 @@ -85,6 +85,10 @@ public ElasticsearchClient elasticRestClient() throws IOException { // esClient.indices().delete(del -> del // .ignoreUnavailable(true) // .index("articles")); +// +// esClient.indices().delete(del -> del +// .ignoreUnavailable(true) +// .index("comments")); // Creating the indexes createSimpleIndex(esClient, USERS); @@ -121,9 +125,9 @@ private void createIndexWithDateMapping(ElasticsearchClient esClient, String ind .index(index) .mappings(m -> m .properties("createdAt", p -> p - .date(d -> d.format("epoch_millis"))) + .date(d -> d)) // epoch_millis is already present by default .properties("updatedAt", p -> p - .date(d -> d.format("epoch_millis"))))); + .date(d -> d)))); } } 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 index b60cf876e..237e4fb2e 100644 --- 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 @@ -33,8 +33,10 @@ import org.springframework.stereotype.Service; import realworld.entity.exception.ResourceAlreadyExistsException; import realworld.entity.exception.ResourceNotFoundException; +import realworld.entity.exception.UnauthorizedException; +import realworld.entity.user.LoginDAO; import realworld.entity.user.Profile; -import realworld.entity.user.User; +import realworld.entity.user.RegisterDAO; import realworld.entity.user.UserDAO; import realworld.entity.user.UserEntity; @@ -52,7 +54,7 @@ public class UserService { private ElasticsearchClient esClient; - @Value("jwt.signing.key") + @Value("${jwt.signing.key}") private String jwtSigningKey; @Autowired @@ -60,7 +62,7 @@ public UserService(ElasticsearchClient esClient) { this.esClient = esClient; } - public UserDAO newUser(UserDAO user) throws IOException { + public UserEntity newUser(RegisterDAO user) throws IOException { // checking if username or email already used. // using a "term" query to match the exact strings @@ -113,7 +115,7 @@ public UserDAO newUser(UserDAO user) throws IOException { .compact(); UserEntity ue = new UserEntity(user.username(), user.email(), - user.password(), jws, user.bio(), user.image(), new ArrayList<>()); + user.password(), jws, "", "", new ArrayList<>()); IndexRequest userReq = IndexRequest.of((id -> id .index("users") @@ -122,10 +124,10 @@ public UserDAO newUser(UserDAO user) throws IOException { esClient.index(userReq); - return user; + return ue; } - public UserDAO login(UserDAO user) throws IOException { + public UserEntity login(LoginDAO user) throws IOException { // term query to match exactly the email and password strings, // using "must" to match both @@ -148,11 +150,12 @@ public UserDAO login(UserDAO user) throws IOException { throw new ResourceNotFoundException("Wrong email or password"); } - return new UserDAO(extractSource(getUser)); + return extractSource(getUser); } - public UserDAO getUserFromToken(String auth) throws IOException { - return new UserDAO(extractSource(getUserEntityFromToken(auth))); + + public UserEntity getUserEntityFromToken(String auth) throws IOException { + return extractSource(getUserSearchFromToken(auth)); } /** @@ -161,7 +164,7 @@ public UserDAO getUserFromToken(String auth) throws IOException { * @return * @throws IOException */ - public SearchResponse getUserEntityFromToken(String auth) throws IOException { + private SearchResponse getUserSearchFromToken(String auth) throws IOException { String token; try { token = auth.split(" ")[1]; @@ -169,7 +172,7 @@ public SearchResponse getUserEntityFromToken(String auth) throws IOE .setSigningKey(TextCodec.BASE64.decode(jwtSigningKey)) .parse(token); } catch (Exception e) { - throw new RuntimeException("Token not recognised",e); + throw new UnauthorizedException("Token missing or not recognised"); } SearchResponse getUser = esClient.search(ss -> ss @@ -187,9 +190,9 @@ public SearchResponse getUserEntityFromToken(String auth) throws IOE return getUser; } - public UserDAO updateUser(String auth, User user) throws IOException { + public UserEntity updateUser(String auth, UserDAO user) throws IOException { - SearchResponse userSearch = getUserEntityFromToken(auth); + SearchResponse userSearch = getUserSearchFromToken(auth); String id = extractId(userSearch); UserEntity userEntity = extractSource(userSearch); @@ -217,7 +220,7 @@ public UserDAO updateUser(String auth, User user) throws IOException { userEntity.following()); updateUser(id, ue); - return new UserDAO(ue); + return ue; } private void updateUser(String id, UserEntity ue) throws IOException { @@ -236,9 +239,9 @@ public Profile getUserProfile(String username, String auth) throws IOException { UserEntity targetUser = findUserByUsername(username); // checking if the user is followed by who's asking - SearchResponse askingUser = getUserEntityFromToken(auth); + UserEntity askingUser = getUserEntityFromToken(auth); boolean following = false; - if (extractSource(askingUser).following().contains(targetUser.username())) { + if (askingUser.following().contains(targetUser.username())) { following = true; } @@ -251,7 +254,7 @@ public Profile followUser(String username, String auth) throws IOException { UserEntity targetUser = findUserByUsername(username); - SearchResponse askingUserSearch = getUserEntityFromToken(auth); + SearchResponse askingUserSearch = getUserSearchFromToken(auth); UserEntity askingUser = extractSource(askingUserSearch); if(askingUser.username().equals(targetUser.username())){ @@ -273,7 +276,7 @@ public Profile unfollowUser(String username, String auth) throws IOException { UserEntity targetUser = findUserByUsername(username); - SearchResponse askingUserSearch = getUserEntityFromToken(auth); + SearchResponse askingUserSearch = getUserSearchFromToken(auth); UserEntity askingUser = extractSource(askingUserSearch); // remove followed user to list if not already present diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleDAO.java index 4dc41923e..462749fa1 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleDAO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleDAO.java @@ -26,6 +26,7 @@ import realworld.entity.user.Author; import java.time.Instant; +import java.time.ZonedDateTime; import java.util.List; @JsonTypeName("article") @@ -44,6 +45,6 @@ public record ArticleDAO( public ArticleDAO(ArticleEntity article) { - this(article.slug(), article.title(), article.description(), article.body(), article.tagList(), article.createdAt(), article.updatedAt(), article.favorited(), article.favoritesCount(), article.author()); + this(article.slug(), article.title(), article.description(), article.body(), article.tagList(), Instant.ofEpochMilli(article.createdAt()), Instant.ofEpochMilli(article.updatedAt()), article.favorited(), article.favoritesCount(), article.author()); } } diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleEntity.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleEntity.java index 57be78bc7..c06351ff2 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleEntity.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleEntity.java @@ -22,6 +22,7 @@ import realworld.entity.user.Author; import java.time.Instant; +import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.List; @@ -31,14 +32,14 @@ public record ArticleEntity( String description, String body, List tagList, - Instant createdAt, - Instant updatedAt, + Long createdAt, + Long updatedAt, boolean favorited, int favoritesCount, List favoritedBy, Author author) { - public ArticleEntity(ArticleCreationDAO article, String slug, Instant createdAt, Instant updatedAt, Author author) { + public ArticleEntity(ArticleCreationDAO article, String slug, Long createdAt, Long 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/entity/article/ArticleForListDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleForListDAO.java index 60eb97615..d68034b6b 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleForListDAO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleForListDAO.java @@ -22,6 +22,7 @@ import realworld.entity.user.Author; import java.time.Instant; +import java.time.ZonedDateTime; import java.util.List; public record ArticleForListDAO( @@ -38,6 +39,10 @@ public record ArticleForListDAO( public ArticleForListDAO(ArticleEntity article) { - this(article.slug(), article.title(), article.description(), article.body(), article.tagList(), article.createdAt(), article.updatedAt(), article.favorited(), article.favoritesCount(), article.author()); + this(article.slug(), article.title(), article.description(), article.body(), article.tagList(), Instant.ofEpochMilli(article.createdAt()), Instant.ofEpochMilli(article.updatedAt()), article.favorited(), article.favoritesCount(), article.author()); + } + + public ArticleForListDAO(ArticleForListDAO 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/entity/comment/CommentDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentDAO.java index be7e8c8f0..d896edbd0 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentDAO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentDAO.java @@ -31,6 +31,6 @@ @JsonTypeInfo(include= As.WRAPPER_OBJECT, use= Id.NAME) public record CommentDAO(Integer id, Instant createdAt, Instant updatedAt, String body, Author author) { public CommentDAO(CommentEntity commentEntity) { - this(commentEntity.id(), commentEntity.createdAt(),commentEntity.updatedAt(), commentEntity.body(), commentEntity.author()); + this(commentEntity.id(), Instant.ofEpochMilli(commentEntity.createdAt()),Instant.ofEpochMilli(commentEntity.updatedAt()), commentEntity.body(), commentEntity.author()); } } diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentEntity.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentEntity.java index cc6f03ef0..486e1ac43 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentEntity.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentEntity.java @@ -21,6 +21,4 @@ import realworld.entity.user.Author; -import java.time.Instant; - -public record CommentEntity(Integer id, Instant createdAt, Instant updatedAt, String body, Author author, String articleSlug) {} +public record CommentEntity(Integer id, Long createdAt, Long updatedAt, String body, Author author, String articleSlug) {} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentForListDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentForListDAO.java index cd010e8c3..63ecf9cee 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentForListDAO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentForListDAO.java @@ -25,6 +25,6 @@ public record CommentForListDAO(Integer id, Instant createdAt, Instant updatedAt, String body, Author author) { public CommentForListDAO(CommentEntity commentEntity) { - this(commentEntity.id(), commentEntity.createdAt(),commentEntity.updatedAt(), commentEntity.body(), commentEntity.author()); + this(commentEntity.id(), Instant.ofEpochMilli(commentEntity.createdAt()),Instant.ofEpochMilli(commentEntity.updatedAt()), commentEntity.body(), commentEntity.author()); } } diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/LoginDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/LoginDAO.java index 85387e20c..bc3358563 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/LoginDAO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/LoginDAO.java @@ -19,5 +19,7 @@ package realworld.entity.user; -public class LoginDAO { +import jakarta.validation.constraints.NotNull; + +public record LoginDAO(@NotNull String email, @NotNull String password) { } diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/RegisterDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/RegisterDAO.java index aa097a7ad..769b2d47e 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/RegisterDAO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/RegisterDAO.java @@ -19,5 +19,7 @@ package realworld.entity.user; -public class RegisterDAO { +import jakarta.validation.constraints.NotNull; + +public record RegisterDAO(@NotNull String username, @NotNull String email, @NotNull String password) { } diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/User.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/User.java deleted file mode 100644 index 7442c05bd..000000000 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/User.java +++ /dev/null @@ -1,41 +0,0 @@ -/* - * 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.entity.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 User( - - String username, - String email, - String token, - String bio, - String image) { - - public User(UserDAO userDAO) { - this(userDAO.username(), userDAO.email(), userDAO.token(), userDAO.bio(), userDAO.image()); - } - -} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserDAO.java index d03ebe85f..3a1104a65 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserDAO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserDAO.java @@ -30,12 +30,11 @@ public record UserDAO( String username, String email, - String password, String token, String bio, String image){ public UserDAO(UserEntity ue){ - this(ue.username(), ue.email(), ue.password(), ue.token(), ue.bio(), ue.image()); + this(ue.username(), ue.email(), ue.token(), ue.bio(), ue.image()); } } 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..0965f99ee --- /dev/null +++ b/examples/realworld-app/rw-database/src/test/java/realworld/db/UserServiceTest.java @@ -0,0 +1,66 @@ +package realworld.db; + +import org.junit.After; +import org.junit.Before; +import org.junit.runner.RunWith; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import org.testcontainers.elasticsearch.ElasticsearchContainer; +import realworld.entity.user.RegisterDAO; +import realworld.entity.user.UserEntity; + +import java.io.IOException; +import java.time.Duration; + +@RunWith(SpringJUnit4ClassRunner.class) +@TestPropertySource(locations = "classpath:test.properties") +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) +@ComponentScan +@SpringBootTest(classes = {UserService.class, UserServiceTest.class}) +public class UserServiceTest { + + @Value("${elasticsearch.server.url}") + private String serverUrl; + + @Autowired + private UserService service; + + private volatile ElasticsearchContainer container; + private static final String image = "docker.elastic.co/elasticsearch/elasticsearch:8.11.0"; + + + // TODO this doesn't work + @Before + public void start(){ + int port = Integer.valueOf(serverUrl.split(":")[2]); + container = new ElasticsearchContainer(image) + .withEnv("ES_JAVA_OPTS", "-Xms256m -Xmx256m") + .withEnv("path.repo", "/tmp") // for snapshots + .withStartupTimeout(Duration.ofSeconds(30)) + .withExposedPorts(port) + .withReuse(true) + .withPassword("test"); + container.start(); + } + + @After + public void stop(){ + container.stop(); + } + + @Test + public void testCreateUser() throws IOException { + RegisterDAO register = new RegisterDAO("userr","mail","pw"); + UserEntity result = service.newUser(register); + assert(result.username().equals(register.username())); + assert(result.email().equals(register.email())); + assert(result.password().equals(register.password())); + } + +} 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..5223b89e8 --- /dev/null +++ b/examples/realworld-app/rw-database/src/test/resources/test.properties @@ -0,0 +1,9 @@ +### +# Test properties +### +jwt.signing.key=c3VjaGFteXN0ZXJ5b3Vyc3VwZXJzZWNyZXR3b3c= +### +# Elasticsearch Settings +### +elasticsearch.server.url=http://localhost:9200 +elasticsearch.api.key=test 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 index 668a17f42..3fc6ab79e 100644 --- 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 @@ -84,8 +84,9 @@ public ResponseEntity getArticleBySlug(@PathVariable String slug) th @GetMapping() public ResponseEntity getArticles(@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) throws IOException { - Articles res = articleService.getArticles(tag, author, favorited, limit, offset); + @RequestParam(required = false) Integer offset, + @RequestHeader(value = "Authorization", required = false) String auth) throws IOException { + Articles res = articleService.getArticles(tag, author, favorited, limit, offset, auth); logger.debug("Returned article list"); return ResponseEntity.ok(res); } @@ -145,12 +146,4 @@ public ResponseEntity deleteComment(@RequestHeader("Authorization") String logger.debug("Deleted comment: {} from article {}", commentId, slug); return ResponseEntity.ok().build(); } - - //TODO REMOVE, JUST FOR TEST - @GetMapping("/allarticles") - public ResponseEntity> getAllArticlesTest() throws IOException { - List res = articleService.allArticles(); - logger.debug("Returned article list"); - 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 index b02fdb9f9..d2fe86165 100644 --- 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 @@ -22,7 +22,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.GetMapping; @@ -33,13 +32,13 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import realworld.db.UserService; -import realworld.entity.user.User; +import realworld.entity.user.LoginDAO; +import realworld.entity.user.RegisterDAO; import realworld.entity.user.UserDAO; +import realworld.entity.user.UserEntity; import java.io.IOException; -import static realworld.utils.Utility.isNullOrBlank; - @CrossOrigin() @RestController @RequestMapping() @@ -55,43 +54,32 @@ public UserController(UserService service) { } @PostMapping("/users") - public ResponseEntity register(@RequestBody UserDAO req) throws IOException { - // TODO consider adding validator - if (isNullOrBlank(req.email()) || isNullOrBlank(req.username()) || isNullOrBlank(req.password())) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .header("EXCEPTION", "missing required field(s)") - .body(null); - } - UserDAO res = service.newUser(req); + public ResponseEntity register(@RequestBody RegisterDAO req) throws IOException { + UserEntity res = service.newUser(req); logger.debug("Registered new user {}", req.username()); - return ResponseEntity.ok(new User(res)); + return ResponseEntity.ok(new UserDAO(res)); } @PostMapping("users/login") - public ResponseEntity login(@RequestBody UserDAO req) throws IOException { - if (isNullOrBlank(req.email()) || isNullOrBlank(req.password())) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .header("EXCEPTION", "missing required field(s)") - .body(null); - } - UserDAO res = service.login(req); - logger.debug("User {} logged in", req.username()); - return ResponseEntity.ok(new User(res)); + public ResponseEntity login(@RequestBody LoginDAO req) throws IOException { + UserEntity res = service.login(req); + logger.debug("User {} logged in", res.username()); + return ResponseEntity.ok(new UserDAO(res)); } @GetMapping("/user") - public ResponseEntity get(@RequestHeader("Authorization") String auth) throws IOException { - UserDAO res = service.getUserFromToken(auth); + public ResponseEntity get(@RequestHeader("Authorization") String auth) throws IOException { + UserEntity res = service.getUserEntityFromToken(auth); logger.debug("Returning info about user {}", res.username()); - return ResponseEntity.ok(new User(res)); + return ResponseEntity.ok(new UserDAO(res)); } @PutMapping("/user") - public ResponseEntity update(@RequestHeader("Authorization") String auth, @RequestBody User req) throws IOException { - UserDAO res = service.updateUser(auth, req); + public ResponseEntity update(@RequestHeader("Authorization") String auth, @RequestBody UserDAO req) throws IOException { + UserEntity res = service.updateUser(auth, req); logger.debug("Updated info for user {}", req.username()); - return ResponseEntity.ok(new User(res)); + return ResponseEntity.ok(new UserDAO(res)); } } 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 index 504eb01bf..bde5884e1 100644 --- 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 @@ -28,6 +28,7 @@ import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; import realworld.entity.exception.ResourceAlreadyExistsException; import realworld.entity.exception.ResourceNotFoundException; +import realworld.entity.exception.UnauthorizedException; import java.io.IOException; import java.util.List; @@ -59,4 +60,20 @@ protected ResponseEntity handleNotFound( return handleExceptionInternal(ex, new RestError(List.of(ex.getLocalizedMessage())), new HttpHeaders(), HttpStatus.NOT_FOUND, request); } + + @ExceptionHandler(value + = { UnauthorizedException.class}) + protected ResponseEntity handleUnauthorized( + RuntimeException ex, WebRequest request) { + return handleExceptionInternal(ex, new RestError(List.of(ex.getLocalizedMessage())), + new HttpHeaders(), HttpStatus.UNAUTHORIZED, request); + } + + @ExceptionHandler(value + = { RuntimeException.class}) + protected ResponseEntity handleUnexpected( + RuntimeException ex, WebRequest request) { + return handleExceptionInternal(ex, new RestError(List.of(ex.getLocalizedMessage())), + new HttpHeaders(), HttpStatus.INTERNAL_SERVER_ERROR, request); + } } From 01e15456a998d5a4a2b49f97e512f805cbfce381 Mon Sep 17 00:00:00 2001 From: Laura Date: Fri, 12 Jan 2024 15:35:57 +0100 Subject: [PATCH 08/22] working testcontainer unit test --- .../main/java/realworld/db/UserService.java | 6 +++ .../java/realworld/db/UserServiceTest.java | 54 +++++-------------- .../src/test/resources/test.properties | 5 -- 3 files changed, 19 insertions(+), 46 deletions(-) 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 index 237e4fb2e..29ff8eb9c 100644 --- 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 @@ -298,6 +298,12 @@ private UserEntity findUserByUsername(String username) throws IOException { return extractSource(getUser); } + /** + * + * @param username + * @return the result of the term query + * @throws IOException + */ private SearchResponse findUserSearchByUsername(String username) throws IOException { // simple term query to match exactly the username string SearchResponse getUser = esClient.search(ss -> ss 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 index 0965f99ee..af9b4aa57 100644 --- 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 @@ -1,66 +1,38 @@ package realworld.db; -import org.junit.After; -import org.junit.Before; -import org.junit.runner.RunWith; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.context.annotation.ComponentScan; -import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.TestPropertySource; -import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; -import org.testcontainers.elasticsearch.ElasticsearchContainer; import realworld.entity.user.RegisterDAO; +import realworld.entity.user.UserDAO; import realworld.entity.user.UserEntity; import java.io.IOException; -import java.time.Duration; +import java.util.Objects; -@RunWith(SpringJUnit4ClassRunner.class) @TestPropertySource(locations = "classpath:test.properties") -@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) -@ComponentScan -@SpringBootTest(classes = {UserService.class, UserServiceTest.class}) +@SpringBootTest(classes = {UserService.class, UserServiceTest.class, ElasticClientTest.class}) public class UserServiceTest { - @Value("${elasticsearch.server.url}") - private String serverUrl; - @Autowired private UserService service; - private volatile ElasticsearchContainer container; - private static final String image = "docker.elastic.co/elasticsearch/elasticsearch:8.11.0"; - - - // TODO this doesn't work - @Before - public void start(){ - int port = Integer.valueOf(serverUrl.split(":")[2]); - container = new ElasticsearchContainer(image) - .withEnv("ES_JAVA_OPTS", "-Xms256m -Xmx256m") - .withEnv("path.repo", "/tmp") // for snapshots - .withStartupTimeout(Duration.ofSeconds(30)) - .withExposedPorts(port) - .withReuse(true) - .withPassword("test"); - container.start(); - } - - @After - public void stop(){ - container.stop(); - } - @Test - public void testCreateUser() throws IOException { + public void testCreateUpdateUser() throws IOException { RegisterDAO register = new RegisterDAO("userr","mail","pw"); UserEntity result = service.newUser(register); assert(result.username().equals(register.username())); assert(result.email().equals(register.email())); assert(result.password().equals(register.password())); + assert(Objects.nonNull(result.token())); + String token = "Token " + result.token(); + + UserDAO update = new UserDAO("new-user","mail","","bio","image"); + result = service.updateUser(token, update); + 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 index 5223b89e8..3b3058aed 100644 --- a/examples/realworld-app/rw-database/src/test/resources/test.properties +++ b/examples/realworld-app/rw-database/src/test/resources/test.properties @@ -2,8 +2,3 @@ # Test properties ### jwt.signing.key=c3VjaGFteXN0ZXJ5b3Vyc3VwZXJzZWNyZXR3b3c= -### -# Elasticsearch Settings -### -elasticsearch.server.url=http://localhost:9200 -elasticsearch.api.key=test From d6fdf939eaee18056b3597c59a92a22b028e8d44 Mon Sep 17 00:00:00 2001 From: Laura Date: Fri, 12 Jan 2024 16:10:37 +0100 Subject: [PATCH 09/22] the rest of the test --- .../java/realworld/db/ElasticClientTest.java | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 examples/realworld-app/rw-database/src/test/java/realworld/db/ElasticClientTest.java 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..c7321173d --- /dev/null +++ b/examples/realworld-app/rw-database/src/test/java/realworld/db/ElasticClientTest.java @@ -0,0 +1,115 @@ + +/* + * 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.auth.AuthScope; +import org.apache.http.auth.UsernamePasswordCredentials; +import org.apache.http.impl.client.BasicCredentialsProvider; +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 org.testcontainers.elasticsearch.ElasticsearchContainer; + +import javax.net.ssl.SSLContext; +import java.io.IOException; +import java.time.Duration; + +import static realworld.constant.Constants.ARTICLES; +import static realworld.constant.Constants.COMMENTS; +import static realworld.constant.Constants.USERS; + +@Configuration +public class ElasticClientTest { + + @Bean + public ElasticsearchClient elasticRestClient() throws IOException { + 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(); + + 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") + ); + RestClient restClient = RestClient.builder(host) + .setHttpClientConfigCallback(hc -> hc + .setDefaultCredentialsProvider(credsProv) + .setSSLContext(sslContext) + ) + .build(); + ObjectMapper mapper = JsonMapper.builder() + .addModule(new JavaTimeModule()) // other modules can be added here + .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)))); + + } + } +} From 70b823342ed03cc68ce0bad8e507a8722d48d488 Mon Sep 17 00:00:00 2001 From: Laura Date: Fri, 12 Jan 2024 17:21:13 +0100 Subject: [PATCH 10/22] style refactor, more documentation --- examples/realworld-app/HELP.md | 23 --------- examples/realworld-app/README.md | 1 + examples/realworld-app/build.gradle | 16 +++---- .../realworld-app/rw-database/build.gradle | 47 +++++++++---------- .../java/realworld/db/ArticleService.java | 27 +++++------ .../java/realworld/db/CommentService.java | 31 ++++++++---- .../main/java/realworld/db/ElasticClient.java | 25 +++------- .../main/java/realworld/db/UserService.java | 29 +++++------- .../entity/article/ArticleCreationDAO.java | 6 ++- .../realworld/entity/article/ArticleDAO.java | 1 - .../entity/article/ArticleEntity.java | 2 - .../entity/article/ArticleForListDAO.java | 1 - .../entity/article/ArticleUpdateDAO.java | 4 +- .../realworld/entity/article/Articles.java | 2 +- .../java/realworld/entity/article/Tags.java | 2 +- .../entity/comment/CommentCreationDAO.java | 5 +- .../realworld/entity/comment/CommentDAO.java | 5 +- .../entity/comment/CommentEntity.java | 3 +- .../entity/comment/CommentForListDAO.java | 3 +- .../ResourceAlreadyExistsException.java | 2 +- .../exception/ResourceNotFoundException.java | 2 +- .../exception/UnauthorizedException.java | 2 +- .../java/realworld/entity/user/Author.java | 8 ++-- .../java/realworld/entity/user/Profile.java | 12 ++--- .../java/realworld/entity/user/UserDAO.java | 12 ++--- .../realworld/entity/user/UserEntity.java | 16 +++---- .../java/realworld/db/ElasticClientTest.java | 3 -- .../java/realworld/db/UserServiceTest.java | 40 ++++++++++++---- examples/realworld-app/rw-rest/build.gradle | 18 +++---- .../realworld/rest/ArticleController.java | 9 ++-- .../java/realworld/rest/error/RestError.java | 4 +- .../rest/error/RestExceptionHandler.java | 10 ++-- examples/realworld-app/rw-server/build.gradle | 19 +++----- .../main/java/realworld/SpringBootApp.java | 10 ++-- .../realworld/config/DefaultProperties.java | 1 + examples/realworld-app/settings.gradle | 8 ++-- 36 files changed, 194 insertions(+), 215 deletions(-) delete mode 100644 examples/realworld-app/HELP.md diff --git a/examples/realworld-app/HELP.md b/examples/realworld-app/HELP.md deleted file mode 100644 index 91d4f4511..000000000 --- a/examples/realworld-app/HELP.md +++ /dev/null @@ -1,23 +0,0 @@ -# Getting Started - -### Reference Documentation -For further reference, please consider the following sections: - -* [Official Gradle documentation](https://docs.gradle.org) -* [Spring Boot Gradle Plugin Reference Guide](https://docs.spring.io/spring-boot/docs/3.2.1/gradle-plugin/reference/html/) -* [Create an OCI image](https://docs.spring.io/spring-boot/docs/3.2.1/gradle-plugin/reference/html/#build-image) -* [Spring Security](https://docs.spring.io/spring-boot/docs/3.2.1/reference/htmlsingle/index.html#web.security) -* [Spring Data Elasticsearch (Access+Driver)](https://docs.spring.io/spring-boot/docs/3.2.1/reference/htmlsingle/index.html#data.nosql.elasticsearch) - -### Guides -The following guides illustrate how to use some features concretely: - -* [Securing a Web Application](https://spring.io/guides/gs/securing-web/) -* [Spring Boot and OAuth2](https://spring.io/guides/tutorials/spring-boot-oauth2/) -* [Authenticating a User with LDAP](https://spring.io/guides/gs/authenticating-ldap/) - -### Additional Links -These additional references should also help you: - -* [Gradle Build Scans – insights for your project's build](https://scans.gradle.com#gradle) - diff --git a/examples/realworld-app/README.md b/examples/realworld-app/README.md index d9d5c6c8d..6c77a3e51 100644 --- a/examples/realworld-app/README.md +++ b/examples/realworld-app/README.md @@ -1,2 +1,3 @@ # realworldapp-test + first test for a realworldapp backend using springboot and elasticsearch diff --git a/examples/realworld-app/build.gradle b/examples/realworld-app/build.gradle index a96055951..2b214da9b 100644 --- a/examples/realworld-app/build.gradle +++ b/examples/realworld-app/build.gradle @@ -1,24 +1,20 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.2.1' - id 'io.spring.dependency-management' version '1.1.4' + 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' + sourceCompatibility = '21' } repositories { - mavenCentral() + mavenCentral() } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-parent:3.2.0' -} - -tasks.named('test') { - useJUnitPlatform() + implementation("org.springframework.boot:spring-boot-starter-parent:3.2.0") } diff --git a/examples/realworld-app/rw-database/build.gradle b/examples/realworld-app/rw-database/build.gradle index a41ddb5f1..503e61d7e 100644 --- a/examples/realworld-app/rw-database/build.gradle +++ b/examples/realworld-app/rw-database/build.gradle @@ -1,40 +1,39 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.2.1' - id 'io.spring.dependency-management' version '1.1.4' + 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' + sourceCompatibility = '21' } repositories { - mavenCentral() + mavenCentral() } -//TODO uniform imports -dependencies { - implementation 'org.springframework.boot:spring-boot-starter:3.2.0' - implementation("org.springframework.boot:spring-boot-starter-validation:3.2.0") - - implementation("co.elastic.clients:elasticsearch-java:8.11.2") - implementation("com.fasterxml.jackson.core:jackson-databind:2.12.3") - implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.16.0") - - implementation ("io.jsonwebtoken:jjwt:0.9.1") - implementation("javax.xml.bind:jaxb-api:2.3.1") - implementation 'com.github.slugify:slugify:3.0.6' - - // MIT - // https://www.testcontainers.org/ - testImplementation("org.testcontainers:testcontainers:1.17.3") - testImplementation("org.testcontainers:elasticsearch:1.17.3") - testImplementation("org.springframework.boot:spring-boot-starter-test:3.2.0") +dependencies { + implementation("org.springframework.boot:spring-boot-starter:3.2.0") + implementation("org.springframework.boot:spring-boot-starter-validation:3.2.0") + + implementation("co.elastic.clients:elasticsearch-java:8.11.4") + implementation("com.fasterxml.jackson.core:jackson-databind:2.12.3") + implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.16.0") + + implementation("io.jsonwebtoken:jjwt:0.9.1") + implementation("javax.xml.bind:jaxb-api:2.3.1") + implementation("com.github.slugify:slugify:3.0.6") + + // MIT + // https://www.testcontainers.org/ + testImplementation("org.testcontainers:testcontainers:1.17.3") + testImplementation("org.testcontainers:elasticsearch:1.17.3") + testImplementation("org.springframework.boot:spring-boot-starter-test:3.2.0") } tasks.named('test') { - useJUnitPlatform() + useJUnitPlatform() } diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java b/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java index bfb5e0a08..d64c9f4a4 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java @@ -47,7 +47,6 @@ import realworld.entity.exception.ResourceNotFoundException; import realworld.entity.exception.UnauthorizedException; import realworld.entity.user.Author; -import realworld.entity.user.UserDAO; import realworld.entity.user.UserEntity; import java.io.IOException; @@ -76,10 +75,8 @@ public ArticleService(ElasticsearchClient esClient, UserService userService) { /** * Creates a new article and saves it into the articles index. - * @param article - * @param auth + * * @return {@link ArticleEntity} - * @throws IOException */ public ArticleEntity newArticle(ArticleCreationDAO article, String auth) throws IOException { @@ -140,7 +137,7 @@ public ArticleDAO updateArticle(ArticleUpdateDAO article, String auth, String sl String newSlug = slug; // if title is being changed, checking if new slug would be unique - if (!isNullOrBlank(article.title())&&!article.title().equals(oldArticle.title())) { + if (!isNullOrBlank(article.title()) && !article.title().equals(oldArticle.title())) { newSlug = generateAndCheckSlug(article.title()); } @@ -248,7 +245,7 @@ public ArticleEntity unfavoriteArticle(String slug, String auth) throws IOExcept public Articles getArticles(String tag, String author, String favorited, Integer limit, Integer offset, String auth) throws IOException { UserEntity user = null; - if(!isNullOrBlank(auth)){ + if (!isNullOrBlank(auth)) { user = userService.getUserEntityFromToken(auth); } List match = new ArrayList<>(); @@ -274,14 +271,14 @@ public Articles getArticles(String tag, String author, String favorited, Integer Query query = new Query.Builder().bool(b -> b.should(match)).build(); SearchResponse getArticle = esClient.search(ss -> ss - .index("articles") - .size(limit) - .from(offset) - .query(query) - .sort(srt -> srt - .field(fld -> fld - .field("updatedAt") - .order(SortOrder.Desc))) + .index("articles") + .size(limit) + .from(offset) + .query(query) + .sort(srt -> srt + .field(fld -> fld + .field("updatedAt") + .order(SortOrder.Desc))) , ArticleEntity.class); UserEntity finalUser = user; @@ -297,7 +294,7 @@ public Articles getArticles(String tag, String author, String favorited, Integer .map(ArticleForListDAO::new) // if auth provided, filling the "following" field of "Author" accordingly .map(a -> { - if(Objects.nonNull(finalUser)){ + if (Objects.nonNull(finalUser)) { boolean following = finalUser.following().contains(a.author().username()); return new ArticleForListDAO(a, new Author(a.author().username(), a.author().email(), a.author().bio(), following)); } 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 index 7c30ec40f..b65d30311 100644 --- 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 @@ -37,10 +37,12 @@ import java.io.IOException; import java.time.Instant; +import java.util.Objects; import java.util.UUID; import java.util.stream.Collectors; import static realworld.utils.Utility.extractSource; +import static realworld.utils.Utility.isNullOrBlank; @Service public class CommentService { @@ -101,7 +103,7 @@ public void deleteComment(String commentId, String slug, String auth) throws IOE // checking if the comment is from the same author UserEntity askingUser = userService.getUserEntityFromToken(auth); - if(!askingUser.username().equals(comment.author().username())){ + if (!askingUser.username().equals(comment.author().username())) { throw new UnauthorizedException("Cannot delete someone else's comment"); } @@ -125,18 +127,31 @@ public void deleteComment(String commentId, String slug, String auth) throws IOE } } - public Comments allCommentsByArticle(String slug) throws IOException { + public Comments allCommentsByArticle(String slug, String auth) throws IOException { + UserEntity user = null; + if (!isNullOrBlank(auth)) { + user = userService.getUserEntityFromToken(auth); + } SearchResponse commentsByArticle = esClient.search(s -> s - .index("comments") - .query(q -> q - .term(t -> t - .field("articleSlug.keyword") - .value(slug)) - ) + .index("comments") + .query(q -> q + .term(t -> t + .field("articleSlug.keyword") + .value(slug)) + ) , CommentEntity.class); + UserEntity finalUser = user; return new Comments(commentsByArticle.hits().hits().stream() .map(x -> new CommentForListDAO(x.source())) + .map(c -> { + if (Objects.nonNull(finalUser)) { + boolean following = finalUser.following().contains(c.author().username()); + return new CommentForListDAO(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 index 57a96ca5e..27167cbd6 100644 --- 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 @@ -52,8 +52,8 @@ public class ElasticClient { /** * Creates the ElasticsearchClient and the indexes needed + * * @return a configured ElasticsearchClient - * @throws IOException */ @Bean public ElasticsearchClient elasticRestClient() throws IOException { @@ -77,19 +77,6 @@ public ElasticsearchClient elasticRestClient() throws IOException { // And create the API client ElasticsearchClient esClient = new ElasticsearchClient(transport); - // TODO remove, for testing -// esClient.indices().delete(del -> del -// .ignoreUnavailable(true) -// .index("users")); -// -// esClient.indices().delete(del -> del -// .ignoreUnavailable(true) -// .index("articles")); -// -// esClient.indices().delete(del -> del -// .ignoreUnavailable(true) -// .index("comments")); - // Creating the indexes createSimpleIndex(esClient, USERS); createIndexWithDateMapping(esClient, ARTICLES); @@ -113,10 +100,12 @@ private void createSimpleIndex(ElasticsearchClient esClient, String index) throw } /** - * 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 + * 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)); 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 index 29ff8eb9c..45263baa9 100644 --- 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 @@ -146,7 +146,7 @@ public UserEntity login(LoginDAO user) throws IOException { ) , UserEntity.class); - if(getUser.hits().hits().isEmpty()){ + if (getUser.hits().hits().isEmpty()) { throw new ResourceNotFoundException("Wrong email or password"); } @@ -160,9 +160,6 @@ public UserEntity getUserEntityFromToken(String auth) throws IOException { /** * - * @param auth - * @return - * @throws IOException */ private SearchResponse getUserSearchFromToken(String auth) throws IOException { String token; @@ -197,16 +194,16 @@ public UserEntity updateUser(String auth, UserDAO user) throws IOException { UserEntity userEntity = extractSource(userSearch); // if the username or email are updated, checking uniqueness - if(!isNullOrBlank(user.username())&&!user.username().equals(userEntity.username())){ + if (!isNullOrBlank(user.username()) && !user.username().equals(userEntity.username())) { SearchResponse newUsernameSearch = findUserSearchByUsername(user.username()); - if(!newUsernameSearch.hits().hits().isEmpty()){ + if (!newUsernameSearch.hits().hits().isEmpty()) { throw new ResourceAlreadyExistsException("Username already exists"); } } - if(!isNullOrBlank(user.email())&&!user.email().equals(userEntity.email())){ + if (!isNullOrBlank(user.email()) && !user.email().equals(userEntity.email())) { SearchResponse newEmailSearch = findUserByEmail(user.email()); - if(!newEmailSearch.hits().hits().isEmpty()){ + if (!newEmailSearch.hits().hits().isEmpty()) { throw new ResourceAlreadyExistsException("Email already in use"); } } @@ -229,7 +226,7 @@ private void updateUser(String id, UserEntity ue) throws IOException { .id(id) .doc(ue) , UserEntity.class); - if(!upUser.result().name().equals("Updated")){ + if (!upUser.result().name().equals("Updated")) { throw new RuntimeException("User update failed"); } } @@ -257,7 +254,7 @@ public Profile followUser(String username, String auth) throws IOException { SearchResponse askingUserSearch = getUserSearchFromToken(auth); UserEntity askingUser = extractSource(askingUserSearch); - if(askingUser.username().equals(targetUser.username())){ + if (askingUser.username().equals(targetUser.username())) { throw new RuntimeException("Cannot follow yourself!"); } @@ -267,7 +264,7 @@ public Profile followUser(String username, String auth) throws IOException { updateUser(extractId(askingUserSearch), askingUser); } - Profile targetUserProfile = new Profile(targetUser,true); + Profile targetUserProfile = new Profile(targetUser, true); return targetUserProfile; } @@ -285,7 +282,7 @@ public Profile unfollowUser(String username, String auth) throws IOException { updateUser(extractId(askingUserSearch), askingUser); } - Profile targetUserProfile = new Profile(targetUser,false); + Profile targetUserProfile = new Profile(targetUser, false); return targetUserProfile; } @@ -299,10 +296,10 @@ private UserEntity findUserByUsername(String username) throws IOException { } /** - * - * @param username - * @return the result of the term query - * @throws IOException + * 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 the result of the term query, a single user. */ private SearchResponse findUserSearchByUsername(String username) throws IOException { // simple term query to match exactly the username string diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleCreationDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleCreationDAO.java index 7b5ae2046..b5dad154c 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleCreationDAO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleCreationDAO.java @@ -23,9 +23,11 @@ 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 ArticleCreationDAO(String title, String description, String body, List tagList){} +@JsonTypeInfo(include = As.WRAPPER_OBJECT, use = Id.NAME) +public record ArticleCreationDAO(@NotNull String title, @NotNull String description, @NotNull String body, + List tagList) {} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleDAO.java index 462749fa1..4236f9fa9 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleDAO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleDAO.java @@ -26,7 +26,6 @@ import realworld.entity.user.Author; import java.time.Instant; -import java.time.ZonedDateTime; import java.util.List; @JsonTypeName("article") diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleEntity.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleEntity.java index c06351ff2..f9f3b0318 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleEntity.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleEntity.java @@ -21,8 +21,6 @@ import realworld.entity.user.Author; -import java.time.Instant; -import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.List; diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleForListDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleForListDAO.java index d68034b6b..e3584e846 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleForListDAO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleForListDAO.java @@ -22,7 +22,6 @@ import realworld.entity.user.Author; import java.time.Instant; -import java.time.ZonedDateTime; import java.util.List; public record ArticleForListDAO( diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleUpdateDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleUpdateDAO.java index 0f5d9fb22..ce4d7ff1c 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleUpdateDAO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleUpdateDAO.java @@ -25,5 +25,5 @@ import com.fasterxml.jackson.annotation.JsonTypeName; @JsonTypeName("article") -@JsonTypeInfo(include= As.WRAPPER_OBJECT, use= Id.NAME) -public record ArticleUpdateDAO (String title, String description, String body){} +@JsonTypeInfo(include = As.WRAPPER_OBJECT, use = Id.NAME) +public record ArticleUpdateDAO(String title, String description, String body) {} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Articles.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Articles.java index affe18f30..7e8267bdf 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Articles.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Articles.java @@ -21,4 +21,4 @@ import java.util.List; -public record Articles (List articles, int articlesCount){} +public record Articles(List articles, int articlesCount) {} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Tags.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Tags.java index b9ff91f91..766028e08 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Tags.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Tags.java @@ -21,4 +21,4 @@ import java.util.List; -public record Tags (List tags){} +public record Tags(List tags) {} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentCreationDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentCreationDAO.java index de1db6a36..a5390b92f 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentCreationDAO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentCreationDAO.java @@ -23,8 +23,9 @@ 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 CommentCreationDAO(String body) { +@JsonTypeInfo(include = As.WRAPPER_OBJECT, use = Id.NAME) +public record CommentCreationDAO(@NotNull String body) { } diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentDAO.java index d896edbd0..2e2a7ddf8 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentDAO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentDAO.java @@ -28,9 +28,10 @@ import java.time.Instant; @JsonTypeName("comment") -@JsonTypeInfo(include= As.WRAPPER_OBJECT, use= Id.NAME) +@JsonTypeInfo(include = As.WRAPPER_OBJECT, use = Id.NAME) public record CommentDAO(Integer id, Instant createdAt, Instant updatedAt, String body, Author author) { + public CommentDAO(CommentEntity commentEntity) { - this(commentEntity.id(), Instant.ofEpochMilli(commentEntity.createdAt()),Instant.ofEpochMilli(commentEntity.updatedAt()), commentEntity.body(), commentEntity.author()); + this(commentEntity.id(), Instant.ofEpochMilli(commentEntity.createdAt()), Instant.ofEpochMilli(commentEntity.updatedAt()), commentEntity.body(), commentEntity.author()); } } diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentEntity.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentEntity.java index 486e1ac43..afad839b4 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentEntity.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentEntity.java @@ -21,4 +21,5 @@ import realworld.entity.user.Author; -public record CommentEntity(Integer id, Long createdAt, Long updatedAt, String body, Author author, String articleSlug) {} +public record CommentEntity(Integer id, Long createdAt, Long updatedAt, String body, Author author, + String articleSlug) {} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentForListDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentForListDAO.java index 63ecf9cee..6d44c89ae 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentForListDAO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentForListDAO.java @@ -24,7 +24,8 @@ import java.time.Instant; public record CommentForListDAO(Integer id, Instant createdAt, Instant updatedAt, String body, Author author) { + public CommentForListDAO(CommentEntity commentEntity) { - this(commentEntity.id(), Instant.ofEpochMilli(commentEntity.createdAt()),Instant.ofEpochMilli(commentEntity.updatedAt()), commentEntity.body(), commentEntity.author()); + this(commentEntity.id(), Instant.ofEpochMilli(commentEntity.createdAt()), Instant.ofEpochMilli(commentEntity.updatedAt()), commentEntity.body(), commentEntity.author()); } } diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/exception/ResourceAlreadyExistsException.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/exception/ResourceAlreadyExistsException.java index 7b095b06f..34f5de2d1 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/exception/ResourceAlreadyExistsException.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/exception/ResourceAlreadyExistsException.java @@ -19,7 +19,7 @@ package realworld.entity.exception; -public class ResourceAlreadyExistsException extends RuntimeException{ +public class ResourceAlreadyExistsException extends RuntimeException { public ResourceAlreadyExistsException(String message) { super(message); diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/exception/ResourceNotFoundException.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/exception/ResourceNotFoundException.java index 8f8089540..aaf08ac45 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/exception/ResourceNotFoundException.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/exception/ResourceNotFoundException.java @@ -19,7 +19,7 @@ package realworld.entity.exception; -public class ResourceNotFoundException extends RuntimeException{ +public class ResourceNotFoundException extends RuntimeException { public ResourceNotFoundException(String message) { super(message); diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/exception/UnauthorizedException.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/exception/UnauthorizedException.java index 78301a742..6abb572d0 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/exception/UnauthorizedException.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/exception/UnauthorizedException.java @@ -19,7 +19,7 @@ package realworld.entity.exception; -public class UnauthorizedException extends RuntimeException{ +public class UnauthorizedException extends RuntimeException { public UnauthorizedException(String message) { super(message); diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Author.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Author.java index cf999c5cd..0a1104b45 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Author.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Author.java @@ -20,10 +20,10 @@ package realworld.entity.user; public record Author( - String username, - String email, - String bio, - boolean following){ + String username, + String email, + String bio, + boolean following) { public Author(UserEntity ue, boolean following) { this(ue.username(), ue.email(), ue.bio(), following); diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Profile.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Profile.java index 002a3fd9b..80cd357e7 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Profile.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Profile.java @@ -25,12 +25,12 @@ 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){ +@JsonTypeInfo(include = As.WRAPPER_OBJECT, use = Id.NAME) +public record Profile( + String username, + String image, + String bio, + boolean following) { public Profile(UserEntity ue, boolean following) { this(ue.username(), ue.image(), ue.bio(), following); diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserDAO.java index 3a1104a65..91100be6c 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserDAO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserDAO.java @@ -28,13 +28,13 @@ @JsonTypeInfo(include = As.WRAPPER_OBJECT, use = Id.NAME) public record UserDAO( - String username, - String email, - String token, - String bio, - String image){ + String username, + String email, + String token, + String bio, + String image) { - public UserDAO(UserEntity ue){ + public UserDAO(UserEntity ue) { this(ue.username(), ue.email(), ue.token(), ue.bio(), ue.image()); } } diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserEntity.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserEntity.java index dc427f3d0..cc53c6173 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserEntity.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserEntity.java @@ -21,13 +21,13 @@ import java.util.List; -public record UserEntity ( +public record UserEntity( - String username, - String email, - String password, - String token, - String bio, - String image, - List following){ + String username, + String email, + String password, + String token, + String bio, + String image, + List following) { } 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 index c7321173d..40dda44fa 100644 --- 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 @@ -28,14 +28,11 @@ 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.auth.AuthScope; import org.apache.http.auth.UsernamePasswordCredentials; import org.apache.http.impl.client.BasicCredentialsProvider; -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 org.testcontainers.elasticsearch.ElasticsearchContainer; 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 index af9b4aa57..057ced03e 100644 --- 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 @@ -1,3 +1,23 @@ + +/* + * 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; @@ -20,19 +40,19 @@ public class UserServiceTest { @Test public void testCreateUpdateUser() throws IOException { - RegisterDAO register = new RegisterDAO("userr","mail","pw"); + RegisterDAO register = new RegisterDAO("userr", "mail", "pw"); UserEntity result = service.newUser(register); - assert(result.username().equals(register.username())); - assert(result.email().equals(register.email())); - assert(result.password().equals(register.password())); - assert(Objects.nonNull(result.token())); + assert (result.username().equals(register.username())); + assert (result.email().equals(register.email())); + assert (result.password().equals(register.password())); + assert (Objects.nonNull(result.token())); String token = "Token " + result.token(); - UserDAO update = new UserDAO("new-user","mail","","bio","image"); + UserDAO update = new UserDAO("new-user", "mail", "", "bio", "image"); result = service.updateUser(token, update); - assert(result.username().equals(update.username())); - assert(result.email().equals(update.email())); - assert(result.bio().equals(update.bio())); - assert(result.image().equals(update.image())); + 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-rest/build.gradle b/examples/realworld-app/rw-rest/build.gradle index 054924722..778242996 100644 --- a/examples/realworld-app/rw-rest/build.gradle +++ b/examples/realworld-app/rw-rest/build.gradle @@ -1,25 +1,21 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.2.1' - id 'io.spring.dependency-management' version '1.1.4' + 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' + sourceCompatibility = '21' } repositories { - mavenCentral() + mavenCentral() } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-web:3.2.0' - implementation('realworldapp:rw-database') -} - -tasks.named('test') { - useJUnitPlatform() + 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 index 3fc6ab79e..f27c686c2 100644 --- 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 @@ -47,7 +47,6 @@ import realworld.entity.comment.Comments; import java.io.IOException; -import java.util.List; @CrossOrigin() @RestController @@ -67,8 +66,6 @@ public ArticleController(ArticleService articleService, CommentService commentSe @PostMapping() public ResponseEntity createArticle(@RequestHeader("Authorization") String auth, @RequestBody ArticleCreationDAO req) throws IOException { - // TODO check null - // TODO consider adding validator ArticleEntity res = articleService.newArticle(req, auth); logger.debug("Created new article: {}", res.slug()); return ResponseEntity.ok(new ArticleDAO(res)); @@ -128,21 +125,21 @@ public ResponseEntity deleteArticle(@RequestHeader("Authorization") String @PostMapping("/{slug}/comments") public ResponseEntity commentArticle(@RequestHeader("Authorization") String auth, @PathVariable String slug, @RequestBody CommentCreationDAO comment) throws IOException { - CommentEntity res = commentService.newComment(comment,slug,auth); + CommentEntity res = commentService.newComment(comment, slug, auth); logger.debug("Commented article: {}", slug); return ResponseEntity.ok(new CommentDAO(res)); } @GetMapping("/{slug}/comments") public ResponseEntity allCommentsByArticle(@RequestHeader("Authorization") String auth, @PathVariable String slug) throws IOException { - Comments res = commentService.allCommentsByArticle(slug); + Comments res = commentService.allCommentsByArticle(slug, auth); logger.debug("Commented article: {}", slug); return ResponseEntity.ok(res); } @DeleteMapping("/{slug}/comments/{commentId}") public ResponseEntity deleteComment(@RequestHeader("Authorization") String auth, @PathVariable String slug, @PathVariable String commentId) throws IOException { - commentService.deleteComment(commentId,slug,auth); + commentService.deleteComment(commentId, slug, auth); 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/error/RestError.java b/examples/realworld-app/rw-rest/src/main/java/realworld/rest/error/RestError.java index 3d88a7aaf..8363783eb 100644 --- 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 @@ -27,6 +27,6 @@ import java.util.List; @JsonTypeName("errors") -@JsonTypeInfo(include= As.WRAPPER_OBJECT, use= Id.NAME) -public record RestError (List body){} +@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 index bde5884e1..186b65359 100644 --- 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 @@ -38,7 +38,7 @@ public class RestExceptionHandler extends ResponseEntityExceptionHandler { @ExceptionHandler(value - = { IOException.class}) + = {IOException.class}) protected ResponseEntity handleIo( RuntimeException ex, WebRequest request) { return handleExceptionInternal(ex, new RestError(List.of("Connection Error with the Database")), @@ -46,7 +46,7 @@ protected ResponseEntity handleIo( } @ExceptionHandler(value - = { ResourceAlreadyExistsException.class}) + = {ResourceAlreadyExistsException.class}) protected ResponseEntity handleConflict( RuntimeException ex, WebRequest request) { return handleExceptionInternal(ex, new RestError(List.of(ex.getLocalizedMessage())), @@ -54,7 +54,7 @@ protected ResponseEntity handleConflict( } @ExceptionHandler(value - = { ResourceNotFoundException.class}) + = {ResourceNotFoundException.class}) protected ResponseEntity handleNotFound( RuntimeException ex, WebRequest request) { return handleExceptionInternal(ex, new RestError(List.of(ex.getLocalizedMessage())), @@ -62,7 +62,7 @@ protected ResponseEntity handleNotFound( } @ExceptionHandler(value - = { UnauthorizedException.class}) + = {UnauthorizedException.class}) protected ResponseEntity handleUnauthorized( RuntimeException ex, WebRequest request) { return handleExceptionInternal(ex, new RestError(List.of(ex.getLocalizedMessage())), @@ -70,7 +70,7 @@ protected ResponseEntity handleUnauthorized( } @ExceptionHandler(value - = { RuntimeException.class}) + = {RuntimeException.class}) protected ResponseEntity handleUnexpected( RuntimeException ex, WebRequest request) { return handleExceptionInternal(ex, new RestError(List.of(ex.getLocalizedMessage())), diff --git a/examples/realworld-app/rw-server/build.gradle b/examples/realworld-app/rw-server/build.gradle index 9f82910e9..44d7ab88f 100644 --- a/examples/realworld-app/rw-server/build.gradle +++ b/examples/realworld-app/rw-server/build.gradle @@ -1,26 +1,21 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.2.1' - id 'io.spring.dependency-management' version '1.1.4' + 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' + sourceCompatibility = '21' } repositories { - mavenCentral() + mavenCentral() } dependencies { - implementation 'org.springframework.boot:spring-boot-starter:3.2.0' - implementation ('realworldapp:rw-rest') - -} - -tasks.named('test') { - useJUnitPlatform() + implementation("org.springframework.boot:spring-boot-starter:3.2.0") + implementation("realworldapp:rw-rest") } diff --git a/examples/realworld-app/rw-server/src/main/java/realworld/SpringBootApp.java b/examples/realworld-app/rw-server/src/main/java/realworld/SpringBootApp.java index 0c3570310..16d03d352 100644 --- a/examples/realworld-app/rw-server/src/main/java/realworld/SpringBootApp.java +++ b/examples/realworld-app/rw-server/src/main/java/realworld/SpringBootApp.java @@ -26,9 +26,9 @@ @SpringBootApplication public class SpringBootApp { - public static void main(String[] args) { - SpringApplication app = new SpringApplication(SpringBootApp.class); - app.setDefaultProperties(DefaultProperties.getDefaultProperties()); - app.run(args); - } + public static void main(String[] args) { + SpringApplication app = new SpringApplication(SpringBootApp.class); + app.setDefaultProperties(DefaultProperties.getDefaultProperties()); + app.run(args); + } } diff --git a/examples/realworld-app/rw-server/src/main/java/realworld/config/DefaultProperties.java b/examples/realworld-app/rw-server/src/main/java/realworld/config/DefaultProperties.java index c0e90bb4f..a7b177a67 100644 --- a/examples/realworld-app/rw-server/src/main/java/realworld/config/DefaultProperties.java +++ b/examples/realworld-app/rw-server/src/main/java/realworld/config/DefaultProperties.java @@ -22,6 +22,7 @@ import java.util.Properties; public class DefaultProperties { + private DefaultProperties() { } diff --git a/examples/realworld-app/settings.gradle b/examples/realworld-app/settings.gradle index 83bf3da24..846924785 100644 --- a/examples/realworld-app/settings.gradle +++ b/examples/realworld-app/settings.gradle @@ -1,5 +1,5 @@ -rootProject.name = 'realworld-app' -includeBuild('rw-server') -includeBuild('rw-database') -includeBuild('rw-rest') +rootProject.name = "realworld-app" +includeBuild("rw-server") +includeBuild("rw-database") +includeBuild("rw-rest") From 5baafa036ff01f57ee1bd6347588169587688437 Mon Sep 17 00:00:00 2001 From: Laura Date: Sun, 14 Jan 2024 20:15:47 +0100 Subject: [PATCH 11/22] fully documented user service, linting --- .../java/realworld/db/ArticleService.java | 33 ++++--- .../java/realworld/db/CommentService.java | 15 +-- .../main/java/realworld/db/UserService.java | 93 +++++++++++-------- .../main/java/realworld/utils/Utility.java | 8 ++ .../realworld/rest/ArticleController.java | 6 +- .../java/realworld/rest/TagsController.java | 3 +- 6 files changed, 95 insertions(+), 63 deletions(-) diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java b/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java index d64c9f4a4..cb18215c1 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java @@ -57,6 +57,8 @@ import java.util.Objects; import java.util.stream.Collectors; +import static realworld.constant.Constants.ARTICLES; +import static realworld.constant.Constants.COMMENTS; import static realworld.utils.Utility.extractId; import static realworld.utils.Utility.extractSource; import static realworld.utils.Utility.isNullOrBlank; @@ -64,8 +66,8 @@ @Service public class ArticleService { - private ElasticsearchClient esClient; - private UserService userService; + private final ElasticsearchClient esClient; + private final UserService userService; @Autowired public ArticleService(ElasticsearchClient esClient, UserService userService) { @@ -91,7 +93,7 @@ public ArticleEntity newArticle(ArticleCreationDAO article, String auth) throws ArticleEntity articleEntity = new ArticleEntity(article, slug, now, now, author); IndexRequest articleReq = IndexRequest.of((id -> id - .index("articles") + .index(ARTICLES) .refresh(Refresh.WaitFor) .document(articleEntity))); @@ -103,16 +105,14 @@ public ArticleEntity newArticle(ArticleCreationDAO article, String auth) throws public SearchResponse singleArticleBySlug(String slug) throws IOException { // using term query to match exactly the slug - SearchResponse getArticle = esClient.search(ss -> ss - .index("articles") + return esClient.search(ss -> ss + .index(ARTICLES) .query(q -> q .term(t -> t .field("slug.keyword") .value(slug)) ) , ArticleEntity.class); - - return getArticle; } public ArticleEntity getArticleBySlug(String slug) throws IOException { @@ -171,7 +171,7 @@ public void deleteArticle(String auth, String slug) throws IOException { // the delete query is very similar to the search query DeleteByQueryResponse deleteArticle = esClient.deleteByQuery(d -> d - .index("articles") + .index(ARTICLES) .waitForCompletion(true) .refresh(true) .query(q -> q @@ -184,8 +184,8 @@ public void deleteArticle(String auth, String slug) throws IOException { } // also delete every comment to the article, using a term query that will match all comments with the same articleSlug - DeleteByQueryResponse commentsByArticle = esClient.deleteByQuery(d -> d - .index("comments") + DeleteByQueryResponse deleteCommentsByArticle = esClient.deleteByQuery(d -> d + .index(COMMENTS) .waitForCompletion(true) .refresh(true) .query(q -> q @@ -193,6 +193,9 @@ public void deleteArticle(String auth, String slug) throws IOException { .field("articleSlug.keyword") .value(slug)) )); + if (deleteCommentsByArticle.deleted() < 1) { + throw new RuntimeException("Failed to delete comments after article deletion"); + } } public ArticleEntity favoriteArticle(String slug, String auth) throws IOException { @@ -271,7 +274,7 @@ public Articles getArticles(String tag, String author, String favorited, Integer Query query = new Query.Builder().bool(b -> b.should(match)).build(); SearchResponse getArticle = esClient.search(ss -> ss - .index("articles") + .index(ARTICLES) .size(limit) .from(offset) .query(query) @@ -314,7 +317,7 @@ public Articles articleFeed(String auth) throws IOException { // the sort options is used afterward to determine which field determines the output order // note how the nested class "author" is easily accessible with the use of the dot notation SearchResponse articlesByAuthors = esClient.search(ss -> ss - .index("articles") + .index(ARTICLES) .query(q -> q .bool(b -> b .filter(f -> f @@ -344,7 +347,7 @@ public Tags allTags() throws IOException { // using a term aggregation is the simplest way to find every distinct tag for each article SearchResponse aggregateTags = esClient.search(s -> s - .index("articles") + .index(ARTICLES) .size(0) // this is to only return aggregation result, and not also search result .aggregations("tags", agg -> agg .terms(ter -> ter @@ -379,8 +382,8 @@ private SearchResponse getArticleEntitySearchResponse(String slug } private void updateArticle(String id, ArticleEntity updatedArticle) throws IOException { - UpdateResponse upArticle = esClient.update(up -> up - .index("articles") + UpdateResponse upArticle = esClient.update(up -> up + .index(ARTICLES) .id(id) .doc(updatedArticle) , ArticleEntity.class); 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 index b65d30311..ab8c2a354 100644 --- 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 @@ -41,15 +41,16 @@ import java.util.UUID; import java.util.stream.Collectors; +import static realworld.constant.Constants.COMMENTS; import static realworld.utils.Utility.extractSource; import static realworld.utils.Utility.isNullOrBlank; @Service public class CommentService { - private ElasticsearchClient esClient; - private UserService userService; - private ArticleService articleService; + private final ElasticsearchClient esClient; + private final UserService userService; + private final ArticleService articleService; @Autowired public CommentService(ElasticsearchClient esClient, UserService userService, ArticleService articleService) { @@ -74,7 +75,7 @@ public CommentEntity newComment(CommentCreationDAO comment, String slug, String CommentEntity commentEntity = new CommentEntity(commentId, now, now, comment.body(), commentAuthor, slug); IndexRequest commentReq = IndexRequest.of((id -> id - .index("comments") + .index(COMMENTS) .refresh(Refresh.WaitFor) .document(commentEntity))); @@ -88,7 +89,7 @@ public void deleteComment(String commentId, String slug, String auth) throws IOE // getting comment by id // using term query to match exactly the id SearchResponse getComment = esClient.search(ss -> ss - .index("comments") + .index(COMMENTS) .query(q -> q .term(t -> t .field("id") @@ -114,7 +115,7 @@ public void deleteComment(String commentId, String slug, String auth) throws IOE // deleting comment by id DeleteByQueryResponse deleteComment = esClient.deleteByQuery(ss -> ss - .index("comments") + .index(COMMENTS) .waitForCompletion(true) .refresh(true) .query(q -> q @@ -133,7 +134,7 @@ public Comments allCommentsByArticle(String slug, String auth) throws IOExceptio user = userService.getUserEntityFromToken(auth); } SearchResponse commentsByArticle = esClient.search(s -> s - .index("comments") + .index(COMMENTS) .query(q -> q .term(t -> t .field("articleSlug.keyword") 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 index 45263baa9..caebc2a89 100644 --- 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 @@ -45,6 +45,7 @@ import java.util.ArrayList; import java.util.Date; +import static realworld.constant.Constants.USERS; import static realworld.utils.Utility.extractId; import static realworld.utils.Utility.extractSource; import static realworld.utils.Utility.isNullOrBlank; @@ -52,7 +53,7 @@ @Service public class UserService { - private ElasticsearchClient esClient; + private final ElasticsearchClient esClient; @Value("${jwt.signing.key}") private String jwtSigningKey; @@ -62,17 +63,21 @@ public UserService(ElasticsearchClient esClient) { this.esClient = esClient; } + /** + * Inserts a new UserEntity into the "users" index, checking beforehand whether the username and email are unique. + *
+ * See {@link UserService#findUserSearchByUsername(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. + *
+ * 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 UserEntity newUser(RegisterDAO user) throws IOException { - // checking if username or email already used. - // using a "term" query to match the exact strings - // (a "match" query would also find words containing the value inserted) - // using "should" to find documents matching either condition. - - // TODO explain keyword -> no tokenizer, stored as is. not important here since everything is a single word - SearchResponse checkUser = esClient.search(ss -> ss - .index("users") + .index(USERS) .query(q -> q .bool(b -> b .should(m -> m @@ -117,22 +122,26 @@ public UserEntity newUser(RegisterDAO user) throws IOException { UserEntity ue = new UserEntity(user.username(), user.email(), user.password(), jws, "", "", new ArrayList<>()); + // creating the index request IndexRequest userReq = IndexRequest.of((id -> id - .index("users") + .index(USERS) .refresh(Refresh.WaitFor) .document(ue))); + // indexing the request (inserting it into to database) esClient.index(userReq); return ue; } + /** + * To identify a user based on their email and passoword, a boolean query similar to the one used in {@link UserService#newUser(RegisterDAO)} is used, 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 UserEntity login(LoginDAO user) throws IOException { - // term query to match exactly the email and password strings, - // using "must" to match both SearchResponse getUser = esClient.search(ss -> ss - .index("users") + .index(USERS) .query(q -> q .bool(b -> b .must(m -> m @@ -159,7 +168,8 @@ public UserEntity getUserEntityFromToken(String auth) throws IOException { } /** - * + * Deserializing and checking the token, then performing a term query (see {@link UserService#findUserSearchByUsername(String)} for details) using the token string to retrieve the corresponding user. + * @return the result of the term query, a single user. */ private SearchResponse getUserSearchFromToken(String auth) throws IOException { String token; @@ -173,7 +183,7 @@ private SearchResponse getUserSearchFromToken(String auth) throws IO } SearchResponse getUser = esClient.search(ss -> ss - .index("users") + .index(USERS) .query(q -> q .term(m -> m .field("token.keyword") @@ -187,6 +197,12 @@ private SearchResponse getUserSearchFromToken(String auth) throws IO return getUser; } + /** + * See {@link UserService#updateUser(String, UserEntity)} + *
+ * Updated a user, checking before if the new username or email would be unique. + * @return the updated user. + */ public UserEntity updateUser(String auth, UserDAO user) throws IOException { SearchResponse userSearch = getUserSearchFromToken(auth); @@ -202,7 +218,7 @@ public UserEntity updateUser(String auth, UserDAO user) throws IOException { } if (!isNullOrBlank(user.email()) && !user.email().equals(userEntity.email())) { - SearchResponse newEmailSearch = findUserByEmail(user.email()); + SearchResponse newEmailSearch = findUserSearchByEmail(user.email()); if (!newEmailSearch.hits().hits().isEmpty()) { throw new ResourceAlreadyExistsException("Email already in use"); } @@ -220,9 +236,13 @@ public UserEntity updateUser(String auth, UserDAO user) throws IOException { return ue; } + /** + * Updates a user, given the updated object and its unique id. + */ private void updateUser(String id, UserEntity ue) throws IOException { - UpdateResponse upUser = esClient.update(up -> up - .index("users") + UpdateResponse upUser = esClient.update(up -> up + .index(USERS) .id(id) .doc(ue) , UserEntity.class); @@ -237,14 +257,9 @@ public Profile getUserProfile(String username, String auth) throws IOException { // checking if the user is followed by who's asking UserEntity askingUser = getUserEntityFromToken(auth); - boolean following = false; - if (askingUser.following().contains(targetUser.username())) { - following = true; - } - - Profile targetUserProfile = new Profile(targetUser, following); + boolean following = askingUser.following().contains(targetUser.username()); - return targetUserProfile; + return new Profile(targetUser, following); } public Profile followUser(String username, String auth) throws IOException { @@ -264,9 +279,8 @@ public Profile followUser(String username, String auth) throws IOException { updateUser(extractId(askingUserSearch), askingUser); } - Profile targetUserProfile = new Profile(targetUser, true); - return targetUserProfile; + return new Profile(targetUser, true); } public Profile unfollowUser(String username, String auth) throws IOException { @@ -282,9 +296,8 @@ public Profile unfollowUser(String username, String auth) throws IOException { updateUser(extractId(askingUserSearch), askingUser); } - Profile targetUserProfile = new Profile(targetUser, false); - return targetUserProfile; + return new Profile(targetUser, false); } private UserEntity findUserByUsername(String username) throws IOException { @@ -296,32 +309,38 @@ private UserEntity findUserByUsername(String username) throws IOException { } /** - * Searches the "users" index for a document containing the exact same username. + * 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 the result of the term query, a single user. */ private SearchResponse findUserSearchByUsername(String username) throws IOException { // simple term query to match exactly the username string - SearchResponse getUser = esClient.search(ss -> ss - .index("users") + return esClient.search(ss -> ss + .index(USERS) .query(q -> q .term(t -> t .field("username.keyword") .value(username))) , UserEntity.class); - return getUser; } - private SearchResponse findUserByEmail(String email) throws IOException { + /** + * Searches the "users" index for a document containing the exact same email. + * See {@link UserService#findUserSearchByUsername(String)} for details. + * @return the result of the term query, a single user. + */ + private SearchResponse findUserSearchByEmail(String email) throws IOException { // simple term query to match exactly the email string - SearchResponse getUser = esClient.search(ss -> ss - .index("users") + return esClient.search(ss -> ss + .index(USERS) .query(q -> q .term(t -> t .field("email.keyword") .value(email))) , UserEntity.class); - return getUser; } } 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 index 40a2f2ca4..57160a903 100644 --- 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 @@ -29,10 +29,18 @@ 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-rest/src/main/java/realworld/rest/ArticleController.java b/examples/realworld-app/rw-rest/src/main/java/realworld/rest/ArticleController.java index f27c686c2..5e2bf07d7 100644 --- 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 @@ -53,8 +53,8 @@ @RequestMapping("/articles") public class ArticleController { - private ArticleService articleService; - private CommentService commentService; + private final ArticleService articleService; + private final CommentService commentService; Logger logger = LoggerFactory.getLogger(UserController.class); @@ -133,7 +133,7 @@ public ResponseEntity commentArticle(@RequestHeader("Authorization") @GetMapping("/{slug}/comments") public ResponseEntity allCommentsByArticle(@RequestHeader("Authorization") String auth, @PathVariable String slug) throws IOException { Comments res = commentService.allCommentsByArticle(slug, auth); - logger.debug("Commented article: {}", slug); + logger.debug("Comments for article: {}", slug); 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 index d028d127d..6bc82c9d9 100644 --- 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 @@ -37,7 +37,7 @@ @RequestMapping("/tags") public class TagsController { - private ArticleService service; + private final ArticleService service; Logger logger = LoggerFactory.getLogger(UserController.class); @@ -49,6 +49,7 @@ public TagsController(ArticleService service) { @GetMapping() public ResponseEntity get() throws IOException { Tags res = service.allTags(); + logger.debug("Retrieved all tags"); return ResponseEntity.ok(res); } } From 9062c8769501881a9d3fa59cb121b371de4691e0 Mon Sep 17 00:00:00 2001 From: Laura Date: Tue, 23 Jan 2024 11:40:43 +0100 Subject: [PATCH 12/22] code style --- examples/realworld-app/build.gradle | 12 ++--- .../realworld-app/rw-database/build.gradle | 44 ++++++++-------- .../java/realworld/db/ArticleService.java | 36 ++++++------- .../java/realworld/db/CommentService.java | 11 ++-- .../main/java/realworld/db/ElasticClient.java | 21 ++++---- .../main/java/realworld/db/UserService.java | 44 ++++++++++------ .../entity/article/ArticleCreationDAO.java | 3 +- .../realworld/entity/article/ArticleDAO.java | 4 +- .../entity/article/ArticleEntity.java | 6 ++- .../entity/article/ArticleForListDAO.java | 8 ++- .../entity/article/ArticleUpdateDAO.java | 3 +- .../realworld/entity/article/Articles.java | 3 +- .../java/realworld/entity/article/Tags.java | 3 +- .../realworld/entity/comment/CommentDAO.java | 4 +- .../entity/comment/CommentEntity.java | 3 +- .../entity/comment/CommentForListDAO.java | 7 ++- .../realworld/entity/comment/Comments.java | 3 +- .../main/java/realworld/utils/Utility.java | 2 + .../java/realworld/db/ElasticClientTest.java | 7 ++- examples/realworld-app/rw-rest/build.gradle | 14 +++--- .../realworld/rest/ArticleController.java | 50 +++++++++---------- .../realworld/rest/ProfileController.java | 18 +++---- .../java/realworld/rest/UserController.java | 12 ++--- .../java/realworld/rest/error/RestError.java | 3 +- examples/realworld-app/rw-server/build.gradle | 14 +++--- 25 files changed, 181 insertions(+), 154 deletions(-) diff --git a/examples/realworld-app/build.gradle b/examples/realworld-app/build.gradle index 2b214da9b..ab0712c01 100644 --- a/examples/realworld-app/build.gradle +++ b/examples/realworld-app/build.gradle @@ -1,20 +1,20 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.2.1' - id 'io.spring.dependency-management' version '1.1.4' + 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' + sourceCompatibility = '21' } repositories { - mavenCentral() + mavenCentral() } dependencies { - implementation("org.springframework.boot:spring-boot-starter-parent:3.2.0") + implementation("org.springframework.boot:spring-boot-starter-parent:3.2.0") } diff --git a/examples/realworld-app/rw-database/build.gradle b/examples/realworld-app/rw-database/build.gradle index 503e61d7e..de4897393 100644 --- a/examples/realworld-app/rw-database/build.gradle +++ b/examples/realworld-app/rw-database/build.gradle @@ -1,39 +1,39 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.2.1' - id 'io.spring.dependency-management' version '1.1.4' + 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' + sourceCompatibility = '21' } repositories { - mavenCentral() + mavenCentral() } dependencies { - implementation("org.springframework.boot:spring-boot-starter:3.2.0") - implementation("org.springframework.boot:spring-boot-starter-validation:3.2.0") - - implementation("co.elastic.clients:elasticsearch-java:8.11.4") - implementation("com.fasterxml.jackson.core:jackson-databind:2.12.3") - implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.16.0") - - implementation("io.jsonwebtoken:jjwt:0.9.1") - implementation("javax.xml.bind:jaxb-api:2.3.1") - implementation("com.github.slugify:slugify:3.0.6") - - // MIT - // https://www.testcontainers.org/ - testImplementation("org.testcontainers:testcontainers:1.17.3") - testImplementation("org.testcontainers:elasticsearch:1.17.3") - testImplementation("org.springframework.boot:spring-boot-starter-test:3.2.0") + implementation("org.springframework.boot:spring-boot-starter:3.2.0") + implementation("org.springframework.boot:spring-boot-starter-validation:3.2.0") + + implementation("co.elastic.clients:elasticsearch-java:8.11.4") + implementation("com.fasterxml.jackson.core:jackson-databind:2.12.3") + implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.16.0") + + implementation("io.jsonwebtoken:jjwt:0.9.1") + implementation("javax.xml.bind:jaxb-api:2.3.1") + implementation("com.github.slugify:slugify:3.0.6") + + // MIT + // https://www.testcontainers.org/ + testImplementation("org.testcontainers:testcontainers:1.17.3") + testImplementation("org.testcontainers:elasticsearch:1.17.3") + testImplementation("org.springframework.boot:spring-boot-starter-test:3.2.0") } tasks.named('test') { - useJUnitPlatform() + useJUnitPlatform() } diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java b/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java index cb18215c1..2e458831f 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java @@ -36,13 +36,7 @@ import com.github.slugify.Slugify; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; -import realworld.entity.article.ArticleCreationDAO; -import realworld.entity.article.ArticleDAO; -import realworld.entity.article.ArticleEntity; -import realworld.entity.article.ArticleForListDAO; -import realworld.entity.article.ArticleUpdateDAO; -import realworld.entity.article.Articles; -import realworld.entity.article.Tags; +import realworld.entity.article.*; import realworld.entity.exception.ResourceAlreadyExistsException; import realworld.entity.exception.ResourceNotFoundException; import realworld.entity.exception.UnauthorizedException; @@ -59,9 +53,7 @@ import static realworld.constant.Constants.ARTICLES; import static realworld.constant.Constants.COMMENTS; -import static realworld.utils.Utility.extractId; -import static realworld.utils.Utility.extractSource; -import static realworld.utils.Utility.isNullOrBlank; +import static realworld.utils.Utility.*; @Service public class ArticleService { @@ -183,7 +175,8 @@ public void deleteArticle(String auth, String slug) throws IOException { throw new RuntimeException("Failed to delete article"); } - // also delete every comment to the article, using a term query that will match all comments with the same articleSlug + // also 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) @@ -211,7 +204,8 @@ public ArticleEntity favoriteArticle(String slug, String auth) throws IOExceptio } article.favoritedBy().add(user.username()); - ArticleEntity updatedArticle = new ArticleEntity(article.slug(), article.title(), article.description(), + ArticleEntity updatedArticle = new ArticleEntity(article.slug(), article.title(), + article.description(), article.body(), article.tagList(), article.createdAt(), article.updatedAt(), true, article.favoritesCount() + 1, article.favoritedBy(), article.author()); @@ -238,7 +232,8 @@ public ArticleEntity unfavoriteArticle(String slug, String auth) throws IOExcept favorited = false; } - ArticleEntity updatedArticle = new ArticleEntity(article.slug(), article.title(), article.description(), + ArticleEntity updatedArticle = new ArticleEntity(article.slug(), article.title(), + article.description(), article.body(), article.tagList(), article.createdAt(), article.updatedAt(), favorited, favoriteCount, article.favoritedBy(), article.author()); @@ -246,15 +241,18 @@ public ArticleEntity unfavoriteArticle(String slug, String auth) throws IOExcept return updatedArticle; } - public Articles getArticles(String tag, String author, String favorited, Integer limit, Integer offset, String auth) throws IOException { + public Articles getArticles(String tag, String author, String favorited, Integer limit, Integer offset, + String auth) throws IOException { UserEntity user = null; if (!isNullOrBlank(auth)) { user = userService.getUserEntityFromToken(auth); } List match = new ArrayList<>(); // since all the parameters for this query are optional, the query must be build conditionally - // using a "match" query instead of a "term" query to allow the use a single word for searching phrases - // for example, filtering for articles with the "cat" tag will also return articles with the "cat food" tag + // using a "match" query instead of a "term" query to allow the use a single word for searching + // phrases + // for example, filtering for articles with the "cat" tag will also return articles with the "cat + // food" tag if (!isNullOrBlank(tag)) { match.add(new Builder() .field("tagList") @@ -299,7 +297,8 @@ public Articles getArticles(String tag, String author, String favorited, Integer .map(a -> { if (Objects.nonNull(finalUser)) { boolean following = finalUser.following().contains(a.author().username()); - return new ArticleForListDAO(a, new Author(a.author().username(), a.author().email(), a.author().bio(), following)); + return new ArticleForListDAO(a, new Author(a.author().username(), + a.author().email(), a.author().bio(), following)); } return a; }) @@ -341,7 +340,8 @@ public Articles articleFeed(String auth) throws IOException { public Tags allTags() throws IOException { - // since the API definition doesn't specify the return order of tags, sorting by document count using "_count" + // since the API definition doesn't specify the return order of tags, sorting by document count + // using "_count" // if alphabetical order is preferred, use "_key" instead NamedValue sort = new NamedValue<>("_count", SortOrder.Desc); 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 index ab8c2a354..5e953ae29 100644 --- 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 @@ -53,7 +53,8 @@ public class CommentService { private final ArticleService articleService; @Autowired - public CommentService(ElasticsearchClient esClient, UserService userService, ArticleService articleService) { + public CommentService(ElasticsearchClient esClient, UserService userService, + ArticleService articleService) { this.esClient = esClient; this.userService = userService; this.articleService = articleService; @@ -72,7 +73,8 @@ public CommentEntity newComment(CommentCreationDAO comment, String slug, String // pre-generating id since it's a field in the comment class Integer commentId = UUID.randomUUID().hashCode(); - CommentEntity commentEntity = new CommentEntity(commentId, now, now, comment.body(), commentAuthor, slug); + CommentEntity commentEntity = new CommentEntity(commentId, now, now, comment.body(), commentAuthor, + slug); IndexRequest commentReq = IndexRequest.of((id -> id .index(COMMENTS) @@ -148,8 +150,9 @@ public Comments allCommentsByArticle(String slug, String auth) throws IOExceptio .map(c -> { if (Objects.nonNull(finalUser)) { boolean following = finalUser.following().contains(c.author().username()); - return new CommentForListDAO(c.id(),c.createdAt(),c.updatedAt(),c.body(), - new Author(c.author().username(), c.author().email(), c.author().bio(), following)); + return new CommentForListDAO(c.id(), c.createdAt(), c.updatedAt(), c.body(), + new Author(c.author().username(), c.author().email(), c.author().bio(), + following)); } return c; }) 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 index 27167cbd6..3106e007f 100644 --- 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 @@ -37,9 +37,7 @@ import java.io.IOException; -import static realworld.constant.Constants.ARTICLES; -import static realworld.constant.Constants.COMMENTS; -import static realworld.constant.Constants.USERS; +import static realworld.constant.Constants.*; @Configuration public class ElasticClient { @@ -61,7 +59,7 @@ public ElasticsearchClient elasticRestClient() throws IOException { // Create the low-level client RestClient restClient = RestClient .builder(HttpHost.create(serverUrl)) - .setDefaultHeaders(new Header[] { + .setDefaultHeaders(new Header[]{ new BasicHeader("Authorization", "ApiKey " + apiKey) }) .build(); @@ -87,8 +85,10 @@ public ElasticsearchClient elasticRestClient() throws IOException { } /** - * Plain simple index - * creation with an + * Plain simple + * index + * creation with an + * * exists check */ private void createSimpleIndex(ElasticsearchClient esClient, String index) throws IOException { @@ -100,11 +100,14 @@ private void createSimpleIndex(ElasticsearchClient esClient, String index) throw } /** - * 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 + * 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 + * href="https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-date-format + * .html">mapping date * format */ private void createIndexWithDateMapping(ElasticsearchClient esClient, String index) throws IOException { 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 index caebc2a89..2f6133e87 100644 --- 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 @@ -34,11 +34,7 @@ import realworld.entity.exception.ResourceAlreadyExistsException; import realworld.entity.exception.ResourceNotFoundException; import realworld.entity.exception.UnauthorizedException; -import realworld.entity.user.LoginDAO; -import realworld.entity.user.Profile; -import realworld.entity.user.RegisterDAO; -import realworld.entity.user.UserDAO; -import realworld.entity.user.UserEntity; +import realworld.entity.user.*; import java.io.IOException; import java.time.Instant; @@ -46,9 +42,7 @@ import java.util.Date; import static realworld.constant.Constants.USERS; -import static realworld.utils.Utility.extractId; -import static realworld.utils.Utility.extractSource; -import static realworld.utils.Utility.isNullOrBlank; +import static realworld.utils.Utility.*; @Service public class UserService { @@ -64,14 +58,18 @@ public UserService(ElasticsearchClient esClient) { } /** - * Inserts a new UserEntity into the "users" index, checking beforehand whether the username and email are unique. + * Inserts a new UserEntity into the "users" index, checking beforehand whether the username and email + * are unique. *
* See {@link UserService#findUserSearchByUsername(String)} for details on how the term query works. *
- * Combining multiple term queries into a single boolean query with "should" occur + * Combining multiple term queries into a single + * boolean query with "should" occur * to match documents fulfilling either conditions. *
- * 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. + * 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 UserEntity newUser(RegisterDAO user) throws IOException { @@ -135,7 +133,10 @@ public UserEntity newUser(RegisterDAO user) throws IOException { } /** - * To identify a user based on their email and passoword, a boolean query similar to the one used in {@link UserService#newUser(RegisterDAO)} is used, with a difference: here "must" is used instead of "should", meaning that the documents must match both conditions at the same time. + * To identify a user based on their email and passoword, a boolean query similar to the one used in + * {@link UserService#newUser(RegisterDAO)} is used, 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 UserEntity login(LoginDAO user) throws IOException { @@ -168,7 +169,10 @@ public UserEntity getUserEntityFromToken(String auth) throws IOException { } /** - * Deserializing and checking the token, then performing a term query (see {@link UserService#findUserSearchByUsername(String)} for details) using the token string to retrieve the corresponding user. + * Deserializing and checking the token, then performing a term query (see + * {@link UserService#findUserSearchByUsername(String)} for details) using the token string to retrieve + * the corresponding user. + * * @return the result of the term query, a single user. */ private SearchResponse getUserSearchFromToken(String auth) throws IOException { @@ -201,6 +205,7 @@ private SearchResponse getUserSearchFromToken(String auth) throws IO * See {@link UserService#updateUser(String, UserEntity)} *
* Updated a user, checking before if the new username or email would be unique. + * * @return the updated user. */ public UserEntity updateUser(String auth, UserDAO user) throws IOException { @@ -225,7 +230,8 @@ public UserEntity updateUser(String auth, UserDAO user) throws IOException { } // null/blank check for every optional field - UserEntity ue = new UserEntity(isNullOrBlank(user.username()) ? userEntity.username() : user.username(), + UserEntity ue = new UserEntity(isNullOrBlank(user.username()) ? userEntity.username() : + user.username(), isNullOrBlank(user.email()) ? userEntity.email() : user.email(), userEntity.password(), userEntity.token(), isNullOrBlank(user.bio()) ? userEntity.bio() : user.bio(), @@ -312,9 +318,14 @@ private UserEntity findUserByUsername(String username) throws IOException { * 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. + * 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. + * 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 the result of the term query, a single user. */ private SearchResponse findUserSearchByUsername(String username) throws IOException { @@ -331,6 +342,7 @@ private SearchResponse findUserSearchByUsername(String username) thr /** * Searches the "users" index for a document containing the exact same email. * See {@link UserService#findUserSearchByUsername(String)} for details. + * * @return the result of the term query, a single user. */ private SearchResponse findUserSearchByEmail(String email) throws IOException { diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleCreationDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleCreationDAO.java index b5dad154c..f4c9fbcc5 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleCreationDAO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleCreationDAO.java @@ -30,4 +30,5 @@ @JsonTypeName("article") @JsonTypeInfo(include = As.WRAPPER_OBJECT, use = Id.NAME) public record ArticleCreationDAO(@NotNull String title, @NotNull String description, @NotNull String body, - List tagList) {} + List tagList) { +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleDAO.java index 4236f9fa9..b0b29c359 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleDAO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleDAO.java @@ -44,6 +44,8 @@ public record ArticleDAO( public ArticleDAO(ArticleEntity article) { - this(article.slug(), article.title(), article.description(), article.body(), article.tagList(), Instant.ofEpochMilli(article.createdAt()), Instant.ofEpochMilli(article.updatedAt()), article.favorited(), article.favoritesCount(), article.author()); + this(article.slug(), article.title(), article.description(), article.body(), article.tagList(), + Instant.ofEpochMilli(article.createdAt()), Instant.ofEpochMilli(article.updatedAt()), + article.favorited(), article.favoritesCount(), article.author()); } } diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleEntity.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleEntity.java index f9f3b0318..733e3dc32 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleEntity.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleEntity.java @@ -37,7 +37,9 @@ public record ArticleEntity( List favoritedBy, Author author) { - public ArticleEntity(ArticleCreationDAO article, String slug, Long createdAt, Long updatedAt, Author author) { - this(slug, article.title(), article.description(), article.body(), article.tagList(), createdAt, updatedAt, false, 0, new ArrayList<>(), author); + public ArticleEntity(ArticleCreationDAO article, String slug, Long createdAt, Long 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/entity/article/ArticleForListDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleForListDAO.java index e3584e846..92dc4be8f 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleForListDAO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleForListDAO.java @@ -38,10 +38,14 @@ public record ArticleForListDAO( public ArticleForListDAO(ArticleEntity article) { - this(article.slug(), article.title(), article.description(), article.body(), article.tagList(), Instant.ofEpochMilli(article.createdAt()), Instant.ofEpochMilli(article.updatedAt()), article.favorited(), article.favoritesCount(), article.author()); + this(article.slug(), article.title(), article.description(), article.body(), article.tagList(), + Instant.ofEpochMilli(article.createdAt()), Instant.ofEpochMilli(article.updatedAt()), + article.favorited(), article.favoritesCount(), article.author()); } public ArticleForListDAO(ArticleForListDAO article, Author author) { - this(article.slug(), article.title(), article.description(), article.body(), article.tagList(), article.createdAt(), article.updatedAt(), article.favorited(), article.favoritesCount(), 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/entity/article/ArticleUpdateDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleUpdateDAO.java index ce4d7ff1c..5cf750875 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleUpdateDAO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleUpdateDAO.java @@ -26,4 +26,5 @@ @JsonTypeName("article") @JsonTypeInfo(include = As.WRAPPER_OBJECT, use = Id.NAME) -public record ArticleUpdateDAO(String title, String description, String body) {} +public record ArticleUpdateDAO(String title, String description, String body) { +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Articles.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Articles.java index 7e8267bdf..ac47d5633 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Articles.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Articles.java @@ -21,4 +21,5 @@ import java.util.List; -public record Articles(List articles, int articlesCount) {} +public record Articles(List articles, int articlesCount) { +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Tags.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Tags.java index 766028e08..0b282fdb9 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Tags.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Tags.java @@ -21,4 +21,5 @@ import java.util.List; -public record Tags(List tags) {} +public record Tags(List tags) { +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentDAO.java index 2e2a7ddf8..783099e6d 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentDAO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentDAO.java @@ -32,6 +32,8 @@ public record CommentDAO(Integer id, Instant createdAt, Instant updatedAt, String body, Author author) { public CommentDAO(CommentEntity commentEntity) { - this(commentEntity.id(), Instant.ofEpochMilli(commentEntity.createdAt()), Instant.ofEpochMilli(commentEntity.updatedAt()), commentEntity.body(), commentEntity.author()); + this(commentEntity.id(), Instant.ofEpochMilli(commentEntity.createdAt()), + Instant.ofEpochMilli(commentEntity.updatedAt()), commentEntity.body(), + commentEntity.author()); } } diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentEntity.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentEntity.java index afad839b4..34dc3f49b 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentEntity.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentEntity.java @@ -22,4 +22,5 @@ import realworld.entity.user.Author; public record CommentEntity(Integer id, Long createdAt, Long updatedAt, String body, Author author, - String articleSlug) {} + String articleSlug) { +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentForListDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentForListDAO.java index 6d44c89ae..ef1d08118 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentForListDAO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentForListDAO.java @@ -23,9 +23,12 @@ import java.time.Instant; -public record CommentForListDAO(Integer id, Instant createdAt, Instant updatedAt, String body, Author author) { +public record CommentForListDAO(Integer id, Instant createdAt, Instant updatedAt, String body, + Author author) { public CommentForListDAO(CommentEntity commentEntity) { - this(commentEntity.id(), Instant.ofEpochMilli(commentEntity.createdAt()), Instant.ofEpochMilli(commentEntity.updatedAt()), commentEntity.body(), commentEntity.author()); + this(commentEntity.id(), Instant.ofEpochMilli(commentEntity.createdAt()), + Instant.ofEpochMilli(commentEntity.updatedAt()), commentEntity.body(), + commentEntity.author()); } } diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/Comments.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/Comments.java index 338b9f618..44938818a 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/Comments.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/Comments.java @@ -21,4 +21,5 @@ import java.util.List; -public record Comments(List comments) {} +public record Comments(List comments) { +} 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 index 57160a903..700f85725 100644 --- 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 @@ -31,6 +31,7 @@ public static boolean isNullOrBlank(String s) { /** * Utility method to be used for single result queries. + * * @return The document id. */ public static String extractId(SearchResponse searchResponse) { @@ -39,6 +40,7 @@ public static String extractId(SearchResponse searchRespo /** * 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) { 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 index 40dda44fa..c0061b1aa 100644 --- 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 @@ -41,9 +41,7 @@ import java.io.IOException; import java.time.Duration; -import static realworld.constant.Constants.ARTICLES; -import static realworld.constant.Constants.COMMENTS; -import static realworld.constant.Constants.USERS; +import static realworld.constant.Constants.*; @Configuration public class ElasticClientTest { @@ -77,7 +75,8 @@ AuthScope.ANY, new UsernamePasswordCredentials("elastic", "changeme") ObjectMapper mapper = JsonMapper.builder() .addModule(new JavaTimeModule()) // other modules can be added here .build(); - ElasticsearchTransport transport = new RestClientTransport(restClient, new JacksonJsonpMapper(mapper)); + ElasticsearchTransport transport = new RestClientTransport(restClient, + new JacksonJsonpMapper(mapper)); ElasticsearchClient esClient = new ElasticsearchClient(transport); // Creating the indexes diff --git a/examples/realworld-app/rw-rest/build.gradle b/examples/realworld-app/rw-rest/build.gradle index 778242996..f0aeb7b4e 100644 --- a/examples/realworld-app/rw-rest/build.gradle +++ b/examples/realworld-app/rw-rest/build.gradle @@ -1,21 +1,21 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.2.1' - id 'io.spring.dependency-management' version '1.1.4' + 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' + sourceCompatibility = '21' } repositories { - mavenCentral() + mavenCentral() } dependencies { - implementation("org.springframework.boot:spring-boot-starter-web:3.2.0") - implementation("realworldapp:rw-database") + 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 index 5e2bf07d7..e62ab9035 100644 --- 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 @@ -23,24 +23,10 @@ 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.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import realworld.db.ArticleService; import realworld.db.CommentService; -import realworld.entity.article.ArticleCreationDAO; -import realworld.entity.article.ArticleDAO; -import realworld.entity.article.ArticleEntity; -import realworld.entity.article.ArticleUpdateDAO; -import realworld.entity.article.Articles; +import realworld.entity.article.*; import realworld.entity.comment.CommentCreationDAO; import realworld.entity.comment.CommentDAO; import realworld.entity.comment.CommentEntity; @@ -65,7 +51,8 @@ public ArticleController(ArticleService articleService, CommentService commentSe } @PostMapping() - public ResponseEntity createArticle(@RequestHeader("Authorization") String auth, @RequestBody ArticleCreationDAO req) throws IOException { + public ResponseEntity createArticle(@RequestHeader("Authorization") String auth, + @RequestBody ArticleCreationDAO req) throws IOException { ArticleEntity res = articleService.newArticle(req, auth); logger.debug("Created new article: {}", res.slug()); return ResponseEntity.ok(new ArticleDAO(res)); @@ -79,8 +66,10 @@ public ResponseEntity getArticleBySlug(@PathVariable String slug) th } @GetMapping() - public ResponseEntity getArticles(@RequestParam(required = false) String tag, @RequestParam(required = false) String author, - @RequestParam(required = false) String favorited, @RequestParam(required = false) Integer limit, + public ResponseEntity getArticles(@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 { Articles res = articleService.getArticles(tag, author, favorited, limit, offset, auth); @@ -96,49 +85,58 @@ public ResponseEntity getFeed(@RequestHeader("Authorization") String a } @PostMapping("/{slug}/favorite") - public ResponseEntity favoriteArticle(@RequestHeader("Authorization") String auth, @PathVariable String slug) throws IOException { + public ResponseEntity favoriteArticle(@RequestHeader("Authorization") String auth, + @PathVariable String slug) throws IOException { ArticleEntity res = articleService.favoriteArticle(slug, auth); logger.debug("Set article: {} as favorite", slug); return ResponseEntity.ok(new ArticleDAO(res)); } @DeleteMapping("/{slug}/favorite") - public ResponseEntity unfavoriteArticle(@RequestHeader("Authorization") String auth, @PathVariable String slug) throws IOException { + public ResponseEntity unfavoriteArticle(@RequestHeader("Authorization") String auth, + @PathVariable String slug) throws IOException { ArticleEntity res = articleService.unfavoriteArticle(slug, auth); logger.debug("Removed article: {} from favorites", slug); return ResponseEntity.ok(new ArticleDAO(res)); } @PutMapping("/{slug}") - public ResponseEntity updateArticle(@RequestHeader("Authorization") String auth, @RequestBody ArticleUpdateDAO req, @PathVariable String slug) throws IOException { + public ResponseEntity updateArticle(@RequestHeader("Authorization") String auth, + @RequestBody ArticleUpdateDAO req, + @PathVariable String slug) throws IOException { ArticleDAO res = articleService.updateArticle(req, auth, slug); logger.debug("Updated article: {}", slug); return ResponseEntity.ok(res); } @DeleteMapping("/{slug}") - public ResponseEntity deleteArticle(@RequestHeader("Authorization") String auth, @PathVariable String slug) throws IOException { + public ResponseEntity deleteArticle(@RequestHeader("Authorization") String auth, + @PathVariable String slug) throws IOException { articleService.deleteArticle(auth, slug); logger.debug("Deleted article: {}", slug); return ResponseEntity.ok().build(); } @PostMapping("/{slug}/comments") - public ResponseEntity commentArticle(@RequestHeader("Authorization") String auth, @PathVariable String slug, @RequestBody CommentCreationDAO comment) throws IOException { + public ResponseEntity commentArticle(@RequestHeader("Authorization") String auth, + @PathVariable String slug, + @RequestBody CommentCreationDAO comment) throws IOException { CommentEntity res = commentService.newComment(comment, slug, auth); logger.debug("Commented article: {}", slug); return ResponseEntity.ok(new CommentDAO(res)); } @GetMapping("/{slug}/comments") - public ResponseEntity allCommentsByArticle(@RequestHeader("Authorization") String auth, @PathVariable String slug) throws IOException { + public ResponseEntity allCommentsByArticle(@RequestHeader("Authorization") String auth, + @PathVariable String slug) throws IOException { Comments res = commentService.allCommentsByArticle(slug, auth); logger.debug("Comments for article: {}", slug); return ResponseEntity.ok(res); } @DeleteMapping("/{slug}/comments/{commentId}") - public ResponseEntity deleteComment(@RequestHeader("Authorization") String auth, @PathVariable String slug, @PathVariable String commentId) throws IOException { + public ResponseEntity deleteComment(@RequestHeader("Authorization") String auth, + @PathVariable String slug, @PathVariable String commentId) throws IOException { commentService.deleteComment(commentId, slug, auth); 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 index be9739847..86a9a12c6 100644 --- 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 @@ -23,14 +23,7 @@ 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.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestHeader; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import realworld.db.UserService; import realworld.entity.user.Profile; @@ -51,21 +44,24 @@ public ProfileController(UserService service) { } @GetMapping("/{username}") - public ResponseEntity get(@PathVariable String username, @RequestHeader("Authorization") String auth) throws IOException { + public ResponseEntity get(@PathVariable String username, + @RequestHeader("Authorization") String auth) throws IOException { Profile res = service.getUserProfile(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 { + 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 { + 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/UserController.java b/examples/realworld-app/rw-rest/src/main/java/realworld/rest/UserController.java index d2fe86165..d8df47840 100644 --- 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 @@ -23,14 +23,7 @@ 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.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import realworld.db.UserService; import realworld.entity.user.LoginDAO; import realworld.entity.user.RegisterDAO; @@ -76,7 +69,8 @@ public ResponseEntity get(@RequestHeader("Authorization") String auth) } @PutMapping("/user") - public ResponseEntity update(@RequestHeader("Authorization") String auth, @RequestBody UserDAO req) throws IOException { + public ResponseEntity update(@RequestHeader("Authorization") String auth, + @RequestBody UserDAO req) throws IOException { UserEntity res = service.updateUser(auth, req); logger.debug("Updated info for user {}", req.username()); return ResponseEntity.ok(new UserDAO(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 index 8363783eb..1390744a4 100644 --- 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 @@ -28,5 +28,6 @@ @JsonTypeName("errors") @JsonTypeInfo(include = As.WRAPPER_OBJECT, use = Id.NAME) -public record RestError(List body) {} +public record RestError(List body) { +} diff --git a/examples/realworld-app/rw-server/build.gradle b/examples/realworld-app/rw-server/build.gradle index 44d7ab88f..67cea3fc9 100644 --- a/examples/realworld-app/rw-server/build.gradle +++ b/examples/realworld-app/rw-server/build.gradle @@ -1,21 +1,21 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.2.1' - id 'io.spring.dependency-management' version '1.1.4' + 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' + sourceCompatibility = '21' } repositories { - mavenCentral() + mavenCentral() } dependencies { - implementation("org.springframework.boot:spring-boot-starter:3.2.0") - implementation("realworldapp:rw-rest") + implementation("org.springframework.boot:spring-boot-starter:3.2.0") + implementation("realworldapp:rw-rest") } From 17fb46b64bdcc602776640007e92020a49d99931 Mon Sep 17 00:00:00 2001 From: Laura Date: Tue, 23 Jan 2024 15:15:24 +0100 Subject: [PATCH 13/22] code style, a e s t e t h i c s --- .../java/realworld/db/ArticleService.java | 164 ++++++++---------- .../java/realworld/db/CommentService.java | 71 +++----- .../main/java/realworld/db/UserService.java | 150 ++++++++-------- .../{ArticleEntity.java => Article.java} | 6 +- ...eationDAO.java => ArticleCreationDTO.java} | 2 +- .../{ArticleDAO.java => ArticleDTO.java} | 4 +- ...ForListDAO.java => ArticleForListDTO.java} | 6 +- ...leUpdateDAO.java => ArticleUpdateDTO.java} | 2 +- .../{Articles.java => ArticlesDTO.java} | 2 +- .../article/{Tags.java => TagsDTO.java} | 2 +- .../{CommentEntity.java => Comment.java} | 4 +- ...eationDAO.java => CommentCreationDTO.java} | 2 +- .../{CommentDAO.java => CommentDTO.java} | 10 +- ...ForListDAO.java => CommentForListDTO.java} | 10 +- .../{Comments.java => CommentsDTO.java} | 2 +- .../java/realworld/entity/user/Author.java | 4 +- .../user/{LoginDAO.java => LoginDTO.java} | 2 +- .../java/realworld/entity/user/Profile.java | 2 +- .../{RegisterDAO.java => RegisterDTO.java} | 2 +- .../user/{UserEntity.java => User.java} | 2 +- .../user/{UserDAO.java => UserDTO.java} | 4 +- .../java/realworld/utils/ArticleIdPair.java | 6 + .../main/java/realworld/utils/UserIdPair.java | 6 + .../java/realworld/db/UserServiceTest.java | 15 +- .../realworld/rest/ArticleController.java | 138 ++++++++++----- .../realworld/rest/ProfileController.java | 2 +- .../java/realworld/rest/TagsController.java | 6 +- .../java/realworld/rest/UserController.java | 34 ++-- 28 files changed, 338 insertions(+), 322 deletions(-) rename examples/realworld-app/rw-database/src/main/java/realworld/entity/article/{ArticleEntity.java => Article.java} (89%) rename examples/realworld-app/rw-database/src/main/java/realworld/entity/article/{ArticleCreationDAO.java => ArticleCreationDTO.java} (95%) rename examples/realworld-app/rw-database/src/main/java/realworld/entity/article/{ArticleDAO.java => ArticleDTO.java} (96%) rename examples/realworld-app/rw-database/src/main/java/realworld/entity/article/{ArticleForListDAO.java => ArticleForListDTO.java} (91%) rename examples/realworld-app/rw-database/src/main/java/realworld/entity/article/{ArticleUpdateDAO.java => ArticleUpdateDTO.java} (94%) rename examples/realworld-app/rw-database/src/main/java/realworld/entity/article/{Articles.java => ArticlesDTO.java} (91%) rename examples/realworld-app/rw-database/src/main/java/realworld/entity/article/{Tags.java => TagsDTO.java} (95%) rename examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/{CommentEntity.java => Comment.java} (85%) rename examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/{CommentCreationDAO.java => CommentCreationDTO.java} (95%) rename examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/{CommentDAO.java => CommentDTO.java} (79%) rename examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/{CommentForListDAO.java => CommentForListDTO.java} (74%) rename examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/{Comments.java => CommentsDTO.java} (93%) rename examples/realworld-app/rw-database/src/main/java/realworld/entity/user/{LoginDAO.java => LoginDTO.java} (93%) rename examples/realworld-app/rw-database/src/main/java/realworld/entity/user/{RegisterDAO.java => RegisterDTO.java} (93%) rename examples/realworld-app/rw-database/src/main/java/realworld/entity/user/{UserEntity.java => User.java} (97%) rename examples/realworld-app/rw-database/src/main/java/realworld/entity/user/{UserDAO.java => UserDTO.java} (95%) create mode 100644 examples/realworld-app/rw-database/src/main/java/realworld/utils/ArticleIdPair.java create mode 100644 examples/realworld-app/rw-database/src/main/java/realworld/utils/UserIdPair.java diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java b/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java index 2e458831f..12d00acb2 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java @@ -41,14 +41,12 @@ import realworld.entity.exception.ResourceNotFoundException; import realworld.entity.exception.UnauthorizedException; import realworld.entity.user.Author; -import realworld.entity.user.UserEntity; +import realworld.entity.user.User; +import realworld.utils.ArticleIdPair; import java.io.IOException; import java.time.Instant; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Objects; +import java.util.*; import java.util.stream.Collectors; import static realworld.constant.Constants.ARTICLES; @@ -59,70 +57,64 @@ public class ArticleService { private final ElasticsearchClient esClient; - private final UserService userService; @Autowired - public ArticleService(ElasticsearchClient esClient, UserService userService) { + public ArticleService(ElasticsearchClient esClient) { this.esClient = esClient; - this.userService = userService; } /** * Creates a new article and saves it into the articles index. * - * @return {@link ArticleEntity} + * @return {@link Article} */ - public ArticleEntity newArticle(ArticleCreationDAO article, String auth) throws IOException { + public Article newArticle(ArticleCreationDTO articleDTO, Author author) throws IOException { // Checking if slug would be unique - String slug = generateAndCheckSlug(article.title()); + String slug = generateAndCheckSlug(articleDTO.title()); // Getting the author - UserEntity ue = userService.getUserEntityFromToken(auth); - Author author = new Author(ue, false); Long now = Instant.now().toEpochMilli(); - ArticleEntity articleEntity = new ArticleEntity(article, slug, now, now, author); + Article article = new Article(articleDTO, slug, now, now, author); - IndexRequest articleReq = IndexRequest.of((id -> id + IndexRequest
articleReq = IndexRequest.of((id -> id .index(ARTICLES) .refresh(Refresh.WaitFor) - .document(articleEntity))); + .document(article))); esClient.index(articleReq); - return articleEntity; + return article; } - public SearchResponse singleArticleBySlug(String slug) throws IOException { + public ArticleIdPair findArticleBySlug(String slug) throws IOException { // using term query to match exactly the slug - return esClient.search(ss -> ss + SearchResponse
getArticle = esClient.search(ss -> ss .index(ARTICLES) .query(q -> q .term(t -> t .field("slug.keyword") .value(slug)) ) - , ArticleEntity.class); - } + , Article.class); - public ArticleEntity getArticleBySlug(String slug) throws IOException { - SearchResponse articleSearch = getArticleEntitySearchResponse(slug); - return extractSource(articleSearch); + if (getArticle.hits().hits().isEmpty()) { + return null; + } + return new ArticleIdPair(extractSource(getArticle), extractId(getArticle)); } - public ArticleDAO updateArticle(ArticleUpdateDAO article, String auth, String slug) throws IOException { + public ArticleDTO updateArticle(ArticleUpdateDTO article, String slug, Author author) throws IOException { // getting original article from slug - SearchResponse articleSearch = getArticleEntitySearchResponse(slug); - String id = extractId(articleSearch); - ArticleEntity oldArticle = extractSource(articleSearch); + 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 - UserEntity ue = userService.getUserEntityFromToken(auth); - Author author = new Author(ue, false); - if (!oldArticle.author().username().equals(author.username())) { throw new UnauthorizedException("Cannot modify article from another author"); } @@ -135,7 +127,7 @@ public ArticleDAO updateArticle(ArticleUpdateDAO article, String auth, String sl Long updatedAt = Instant.now().toEpochMilli(); - ArticleEntity updatedArticle = new ArticleEntity(newSlug, + 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(), @@ -144,20 +136,18 @@ public ArticleDAO updateArticle(ArticleUpdateDAO article, String auth, String sl oldArticle.favoritedBy(), oldArticle.author()); updateArticle(id, updatedArticle); - return new ArticleDAO(updatedArticle); + return new ArticleDTO(updatedArticle); } - public void deleteArticle(String auth, String slug) throws IOException { + public void deleteArticle(String slug, Author author) throws IOException { // getting article from slug - SearchResponse articleSearch = getArticleEntitySearchResponse(slug); - ArticleEntity articleEntity = extractSource(articleSearch); + ArticleIdPair articlePair = Optional.ofNullable(findArticleBySlug(slug)) + .orElseThrow(() -> new ResourceNotFoundException("Article not found")); + Article article = articlePair.article(); // checking if author is the same - UserEntity ue = userService.getUserEntityFromToken(auth); - Author author = new Author(ue, false); - - if (!articleEntity.author().username().equals(author.username())) { + if (!article.author().username().equals(author.username())) { throw new UnauthorizedException("Cannot delete article from another author"); } @@ -191,20 +181,19 @@ public void deleteArticle(String auth, String slug) throws IOException { } } - public ArticleEntity favoriteArticle(String slug, String auth) throws IOException { - UserEntity user = userService.getUserEntityFromToken(auth); - - SearchResponse articleSearch = getArticleEntitySearchResponse(slug); - String id = extractId(articleSearch); - ArticleEntity article = extractSource(articleSearch); + 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 favorited - if (article.favoritedBy().contains(user.username())) { + if (article.favoritedBy().contains(username)) { return article; } - article.favoritedBy().add(user.username()); - ArticleEntity updatedArticle = new ArticleEntity(article.slug(), article.title(), + 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()); @@ -213,26 +202,25 @@ public ArticleEntity favoriteArticle(String slug, String auth) throws IOExceptio return updatedArticle; } - public ArticleEntity unfavoriteArticle(String slug, String auth) throws IOException { - UserEntity user = userService.getUserEntityFromToken(auth); - - SearchResponse articleSearch = getArticleEntitySearchResponse(slug); - String id = extractId(articleSearch); - ArticleEntity article = extractSource(articleSearch); + 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 wasn't favorited before - if (!article.favoritedBy().contains(user.username())) { + // checking if article was not marked as favorite before + if (!article.favoritedBy().contains(username)) { return article; } - article.favoritedBy().remove(user.username()); + article.favoritedBy().remove(username); int favoriteCount = article.favoritesCount() - 1; boolean favorited = article.favorited(); if (favoriteCount == 0) { favorited = false; } - ArticleEntity updatedArticle = new ArticleEntity(article.slug(), article.title(), + Article updatedArticle = new Article(article.slug(), article.title(), article.description(), article.body(), article.tagList(), article.createdAt(), article.updatedAt(), favorited, favoriteCount, article.favoritedBy(), article.author()); @@ -241,12 +229,9 @@ public ArticleEntity unfavoriteArticle(String slug, String auth) throws IOExcept return updatedArticle; } - public Articles getArticles(String tag, String author, String favorited, Integer limit, Integer offset, - String auth) throws IOException { - UserEntity user = null; - if (!isNullOrBlank(auth)) { - user = userService.getUserEntityFromToken(auth); - } + public ArticlesDTO findArticles(String tag, String author, String favorited, Integer limit, + Integer offset, + Optional user) throws IOException { List match = new ArrayList<>(); // since all the parameters for this query are optional, the query must be build conditionally // using a "match" query instead of a "term" query to allow the use a single word for searching @@ -271,7 +256,7 @@ public Articles getArticles(String tag, String author, String favorited, Integer Query query = new Query.Builder().bool(b -> b.should(match)).build(); - SearchResponse getArticle = esClient.search(ss -> ss + SearchResponse
getArticle = esClient.search(ss -> ss .index(ARTICLES) .size(limit) .from(offset) @@ -280,10 +265,9 @@ public Articles getArticles(String tag, String author, String favorited, Integer .field(fld -> fld .field("updatedAt") .order(SortOrder.Desc))) - , ArticleEntity.class); + , Article.class); - UserEntity finalUser = user; - return new Articles(getArticle.hits().hits() + return new ArticlesDTO(getArticle.hits().hits() .stream() .map(Hit::source) // if tag specified, put that tag first in the array @@ -292,12 +276,12 @@ public Articles getArticles(String tag, String author, String favorited, Integer Collections.swap(a.tagList(), a.tagList().indexOf(tag), 0); } }) - .map(ArticleForListDAO::new) + .map(ArticleForListDTO::new) // if auth provided, filling the "following" field of "Author" accordingly .map(a -> { - if (Objects.nonNull(finalUser)) { - boolean following = finalUser.following().contains(a.author().username()); - return new ArticleForListDAO(a, new Author(a.author().username(), + 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; @@ -305,17 +289,15 @@ public Articles getArticles(String tag, String author, String favorited, Integer .collect(Collectors.toList()), getArticle.hits().hits().size()); } - public Articles articleFeed(String auth) throws IOException { - UserEntity userEntity = userService.getUserEntityFromToken(auth); - + public ArticlesDTO generateArticleFeed(User user) throws IOException { // preparing authors filter from user data - List authorsFilter = userEntity.following().stream() + List authorsFilter = user.following().stream() .map(FieldValue::of).toList(); // a terms query can be used to query for multiple values, like authors. // the sort options is used afterward to determine which field determines the output order // note how the nested class "author" is easily accessible with the use of the dot notation - SearchResponse articlesByAuthors = esClient.search(ss -> ss + SearchResponse
articlesByAuthors = esClient.search(ss -> ss .index(ARTICLES) .query(q -> q .bool(b -> b @@ -328,17 +310,17 @@ public Articles articleFeed(String auth) throws IOException { .field(fld -> fld .field("updatedAt") .order(SortOrder.Desc))) - , ArticleEntity.class); + , Article.class); - return new Articles(articlesByAuthors.hits().hits() + return new ArticlesDTO(articlesByAuthors.hits().hits() .stream() .map(Hit::source) - .map(ArticleForListDAO::new) + .map(ArticleForListDTO::new) .collect(Collectors.toList()), articlesByAuthors.hits().hits().size()); } - public Tags allTags() throws IOException { + public TagsDTO findAllTags() throws IOException { // since the API definition doesn't specify the return order of tags, sorting by document count // using "_count" @@ -357,7 +339,7 @@ public Tags allTags() throws IOException { Aggregation.class ); - return new Tags(aggregateTags.aggregations().get("tags") + return new TagsDTO(aggregateTags.aggregations().get("tags") .sterms().buckets() .array().stream() .map(st -> st.key().stringValue()) @@ -367,26 +349,18 @@ public Tags allTags() throws IOException { private String generateAndCheckSlug(String title) throws IOException { String slug = Slugify.builder().build().slugify(title); - if (!singleArticleBySlug(slug).hits().hits().isEmpty()) { + if (Objects.nonNull(findArticleBySlug(slug))) { throw new ResourceAlreadyExistsException("Article slug already exists, please change the title"); } return slug; } - private SearchResponse getArticleEntitySearchResponse(String slug) throws IOException { - SearchResponse articleSearch = singleArticleBySlug(slug); - if (articleSearch.hits().hits().isEmpty()) { - throw new ResourceNotFoundException("Article not found"); - } - return articleSearch; - } - - private void updateArticle(String id, ArticleEntity updatedArticle) throws IOException { - UpdateResponse upArticle = esClient.update(up -> up + private void updateArticle(String id, Article updatedArticle) throws IOException { + UpdateResponse
upArticle = esClient.update(up -> up .index(ARTICLES) .id(id) .doc(updatedArticle) - , ArticleEntity.class); + , Article.class); if (!upArticle.result().name().equals("Updated")) { throw new RuntimeException("Article update failed"); } 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 index 5e953ae29..740481744 100644 --- 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 @@ -26,87 +26,75 @@ import co.elastic.clients.elasticsearch.core.SearchResponse; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; -import realworld.entity.comment.CommentCreationDAO; -import realworld.entity.comment.CommentEntity; -import realworld.entity.comment.CommentForListDAO; -import realworld.entity.comment.Comments; +import realworld.entity.comment.Comment; +import realworld.entity.comment.CommentCreationDTO; +import realworld.entity.comment.CommentForListDTO; +import realworld.entity.comment.CommentsDTO; import realworld.entity.exception.ResourceNotFoundException; import realworld.entity.exception.UnauthorizedException; import realworld.entity.user.Author; -import realworld.entity.user.UserEntity; +import realworld.entity.user.User; import java.io.IOException; import java.time.Instant; -import java.util.Objects; +import java.util.Optional; import java.util.UUID; import java.util.stream.Collectors; import static realworld.constant.Constants.COMMENTS; import static realworld.utils.Utility.extractSource; -import static realworld.utils.Utility.isNullOrBlank; @Service public class CommentService { private final ElasticsearchClient esClient; - private final UserService userService; - private final ArticleService articleService; @Autowired - public CommentService(ElasticsearchClient esClient, UserService userService, - ArticleService articleService) { + public CommentService(ElasticsearchClient esClient) { this.esClient = esClient; - this.userService = userService; - this.articleService = articleService; } - public CommentEntity newComment(CommentCreationDAO comment, String slug, String auth) throws IOException { - - // checking if the article exists - articleService.getArticleBySlug(slug); - - // getting the comment's author - UserEntity commentUser = userService.getUserEntityFromToken(auth); + public Comment newComment(CommentCreationDTO commentDTO, String slug, User user) throws IOException { // assuming you cannot follow yourself - Author commentAuthor = new Author(commentUser, false); + Author commentAuthor = new Author(user, false); Long now = Instant.now().toEpochMilli(); // pre-generating id since it's a field in the comment class Integer commentId = UUID.randomUUID().hashCode(); - CommentEntity commentEntity = new CommentEntity(commentId, now, now, comment.body(), commentAuthor, + Comment comment = new Comment(commentId, now, now, commentDTO.body(), commentAuthor, slug); - IndexRequest commentReq = IndexRequest.of((id -> id + IndexRequest commentReq = IndexRequest.of((id -> id .index(COMMENTS) .refresh(Refresh.WaitFor) - .document(commentEntity))); + .document(comment))); + esClient.index(commentReq); - return commentEntity; + return comment; } - public void deleteComment(String commentId, String slug, String auth) throws IOException { + public void deleteComment(String commentId, String slug, String username) throws IOException { // getting comment by id // using term query to match exactly the id - SearchResponse getComment = esClient.search(ss -> ss + SearchResponse getComment = esClient.search(ss -> ss .index(COMMENTS) .query(q -> q .term(t -> t .field("id") .value(commentId)) ) - , CommentEntity.class); + , Comment.class); if (getComment.hits().hits().isEmpty()) { throw new ResourceNotFoundException("Comment not found"); } - CommentEntity comment = extractSource(getComment); + Comment comment = extractSource(getComment); // checking if the comment is from the same author - UserEntity askingUser = userService.getUserEntityFromToken(auth); - if (!askingUser.username().equals(comment.author().username())) { + if (!username.equals(comment.author().username())) { throw new UnauthorizedException("Cannot delete someone else's comment"); } @@ -130,27 +118,22 @@ public void deleteComment(String commentId, String slug, String auth) throws IOE } } - public Comments allCommentsByArticle(String slug, String auth) throws IOException { - UserEntity user = null; - if (!isNullOrBlank(auth)) { - user = userService.getUserEntityFromToken(auth); - } - SearchResponse commentsByArticle = esClient.search(s -> s + 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)) ) - , CommentEntity.class); + , Comment.class); - UserEntity finalUser = user; - return new Comments(commentsByArticle.hits().hits().stream() - .map(x -> new CommentForListDAO(x.source())) + return new CommentsDTO(commentsByArticle.hits().hits().stream() + .map(x -> new CommentForListDTO(x.source())) .map(c -> { - if (Objects.nonNull(finalUser)) { - boolean following = finalUser.following().contains(c.author().username()); - return new CommentForListDAO(c.id(), c.createdAt(), c.updatedAt(), c.body(), + 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)); } 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 index 2f6133e87..1c16c780a 100644 --- 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 @@ -35,11 +35,14 @@ import realworld.entity.exception.ResourceNotFoundException; import realworld.entity.exception.UnauthorizedException; import realworld.entity.user.*; +import realworld.utils.UserIdPair; import java.io.IOException; import java.time.Instant; import java.util.ArrayList; import java.util.Date; +import java.util.Objects; +import java.util.Optional; import static realworld.constant.Constants.USERS; import static realworld.utils.Utility.*; @@ -58,10 +61,10 @@ public UserService(ElasticsearchClient esClient) { } /** - * Inserts a new UserEntity into the "users" index, checking beforehand whether the username and email + * Inserts a new Userinto the "users" index, checking beforehand whether the username and email * are unique. *
- * See {@link UserService#findUserSearchByUsername(String)} for details on how the term query works. + * 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 @@ -72,9 +75,9 @@ public UserService(ElasticsearchClient esClient) { * * @return The newly registered user. */ - public UserEntity newUser(RegisterDAO user) throws IOException { + public User newUser(RegisterDTO user) throws IOException { - SearchResponse checkUser = esClient.search(ss -> ss + SearchResponse checkUser = esClient.search(ss -> ss .index(USERS) .query(q -> q .bool(b -> b @@ -86,7 +89,7 @@ public UserEntity newUser(RegisterDAO user) throws IOException { .term(mc -> mc .field("username.keyword") .value(user.username()))))) - , UserEntity.class); + , User.class); checkUser.hits().hits().stream() .map(Hit::source) @@ -117,11 +120,11 @@ public UserEntity newUser(RegisterDAO user) throws IOException { ) .compact(); - UserEntity ue = new UserEntity(user.username(), user.email(), + User ue = new User(user.username(), user.email(), user.password(), jws, "", "", new ArrayList<>()); // creating the index request - IndexRequest userReq = IndexRequest.of((id -> id + IndexRequest userReq = IndexRequest.of((id -> id .index(USERS) .refresh(Refresh.WaitFor) .document(ue))); @@ -134,14 +137,14 @@ public UserEntity newUser(RegisterDAO user) throws IOException { /** * To identify a user based on their email and passoword, a boolean query similar to the one used in - * {@link UserService#newUser(RegisterDAO)} is used, with a difference: here "must" is used instead of + * {@link UserService#newUser(RegisterDTO)} is used, 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 UserEntity login(LoginDAO user) throws IOException { + public User authenticateUser(LoginDTO user) throws IOException { - SearchResponse getUser = esClient.search(ss -> ss + SearchResponse getUser = esClient.search(ss -> ss .index(USERS) .query(q -> q .bool(b -> b @@ -154,7 +157,7 @@ public UserEntity login(LoginDAO user) throws IOException { .field("password.keyword") .value(user.password())))) ) - , UserEntity.class); + , User.class); if (getUser.hits().hits().isEmpty()) { throw new ResourceNotFoundException("Wrong email or password"); @@ -163,19 +166,14 @@ public UserEntity login(LoginDAO user) throws IOException { return extractSource(getUser); } - - public UserEntity getUserEntityFromToken(String auth) throws IOException { - return extractSource(getUserSearchFromToken(auth)); - } - /** * Deserializing and checking the token, then performing a term query (see - * {@link UserService#findUserSearchByUsername(String)} for details) using the token string to retrieve + * {@link UserService#findUserByUsername(String)} for details) using the token string to retrieve * the corresponding user. * - * @return the result of the term query, a single user. + * @return a pair containing the result of the term query, a single user, with its id. */ - private SearchResponse getUserSearchFromToken(String auth) throws IOException { + public UserIdPair findUserByToken(String auth) throws IOException { String token; try { token = auth.split(" ")[1]; @@ -186,59 +184,58 @@ private SearchResponse getUserSearchFromToken(String auth) throws IO throw new UnauthorizedException("Token missing or not recognised"); } - SearchResponse getUser = esClient.search(ss -> ss + SearchResponse getUser = esClient.search(ss -> ss .index(USERS) .query(q -> q .term(m -> m .field("token.keyword") .value(token)) ) - , UserEntity.class); + , User.class); if (getUser.hits().hits().isEmpty()) { throw new ResourceNotFoundException("Token not assigned to any user"); } - return getUser; + return new UserIdPair(extractSource(getUser), extractId(getUser)); } /** - * See {@link UserService#updateUser(String, UserEntity)} + * See {@link UserService#updateUser(String, User)} *
* Updated a user, checking before if the new username or email would be unique. * * @return the updated user. */ - public UserEntity updateUser(String auth, UserDAO user) throws IOException { + public User updateUser(UserDTO userDTO, String auth) throws IOException { - SearchResponse userSearch = getUserSearchFromToken(auth); - String id = extractId(userSearch); - UserEntity userEntity = extractSource(userSearch); + UserIdPair userPair = findUserByToken(auth); + User user = userPair.user(); // if the username or email are updated, checking uniqueness - if (!isNullOrBlank(user.username()) && !user.username().equals(userEntity.username())) { - SearchResponse newUsernameSearch = findUserSearchByUsername(user.username()); - if (!newUsernameSearch.hits().hits().isEmpty()) { + 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(user.email()) && !user.email().equals(userEntity.email())) { - SearchResponse newEmailSearch = findUserSearchByEmail(user.email()); - if (!newEmailSearch.hits().hits().isEmpty()) { + 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 - UserEntity ue = new UserEntity(isNullOrBlank(user.username()) ? userEntity.username() : - user.username(), - isNullOrBlank(user.email()) ? userEntity.email() : user.email(), - userEntity.password(), userEntity.token(), - isNullOrBlank(user.bio()) ? userEntity.bio() : user.bio(), - isNullOrBlank(user.image()) ? userEntity.image() : user.image(), - userEntity.following()); - - updateUser(id, ue); + User ue = 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.following()); + + updateUser(userPair.id(), ue); return ue; } @@ -246,34 +243,38 @@ public UserEntity updateUser(String auth, UserDAO user) throws IOException { * Updates a user, given the updated object and its unique id. */ - private void updateUser(String id, UserEntity ue) throws IOException { - UpdateResponse upUser = esClient.update(up -> up + private void updateUser(String id, User ue) throws IOException { + UpdateResponse upUser = esClient.update(up -> up .index(USERS) .id(id) .doc(ue) - , UserEntity.class); + , User.class); if (!upUser.result().name().equals("Updated")) { throw new RuntimeException("User update failed"); } } - public Profile getUserProfile(String username, String auth) throws IOException { + public Profile findUserProfile(String username, String auth) throws IOException { - UserEntity targetUser = findUserByUsername(username); + 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 - UserEntity askingUser = getUserEntityFromToken(auth); - boolean following = askingUser.following().contains(targetUser.username()); + UserIdPair askingUserPair = findUserByToken(auth); + boolean following = askingUserPair.user().following().contains(targetUser.username()); return new Profile(targetUser, following); } public Profile followUser(String username, String auth) throws IOException { - UserEntity targetUser = findUserByUsername(username); + UserIdPair targetUserPair = Optional.ofNullable(findUserByUsername(username)) + .orElseThrow(() -> new ResourceNotFoundException("User not found")); + User targetUser = targetUserPair.user(); - SearchResponse askingUserSearch = getUserSearchFromToken(auth); - UserEntity askingUser = extractSource(askingUserSearch); + UserIdPair askingUserPair = findUserByToken(auth); + User askingUser = askingUserPair.user(); if (askingUser.username().equals(targetUser.username())) { throw new RuntimeException("Cannot follow yourself!"); @@ -283,37 +284,30 @@ public Profile followUser(String username, String auth) throws IOException { if (!askingUser.following().contains(targetUser.username())) { askingUser.following().add(targetUser.username()); - updateUser(extractId(askingUserSearch), askingUser); + updateUser(askingUserPair.id(), askingUser); } return new Profile(targetUser, true); } 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(); - UserEntity targetUser = findUserByUsername(username); - - SearchResponse askingUserSearch = getUserSearchFromToken(auth); - UserEntity askingUser = extractSource(askingUserSearch); + 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(extractId(askingUserSearch), askingUser); + updateUser(askingUserPair.id(), askingUser); } return new Profile(targetUser, false); } - private UserEntity findUserByUsername(String username) throws IOException { - SearchResponse getUser = findUserSearchByUsername(username); - if (getUser.hits().hits().isEmpty()) { - throw new ResourceNotFoundException("Target user not found"); - } - return extractSource(getUser); - } - /** * Searches the "users" index for a document containing the exact same username. @@ -326,33 +320,41 @@ private UserEntity findUserByUsername(String username) throws IOException { * property of the field allows to use the original value of the string while querying, instead of the * processed/tokenized value. * - * @return the result of the term query, a single user. + * @return a pair containing the result of the term query, a single user, with its id. */ - private SearchResponse findUserSearchByUsername(String username) throws IOException { + private UserIdPair findUserByUsername(String username) throws IOException { // simple term query to match exactly the username string - return esClient.search(ss -> ss + SearchResponse getUser = esClient.search(ss -> ss .index(USERS) .query(q -> q .term(t -> t .field("username.keyword") .value(username))) - , UserEntity.class); + , 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#findUserSearchByUsername(String)} for details. + * See {@link UserService#findUserByUsername(String)} for details. * * @return the result of the term query, a single user. */ - private SearchResponse findUserSearchByEmail(String email) throws IOException { + private UserIdPair findUserByEmail(String email) throws IOException { // simple term query to match exactly the email string - return esClient.search(ss -> ss + SearchResponse getUser = esClient.search(ss -> ss .index(USERS) .query(q -> q .term(t -> t .field("email.keyword") .value(email))) - , UserEntity.class); + , User.class); + if (getUser.hits().hits().isEmpty()) { + return null; + } + return new UserIdPair(extractSource(getUser), extractId(getUser)); } } diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleEntity.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Article.java similarity index 89% rename from examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleEntity.java rename to examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Article.java index 733e3dc32..21b1d7b81 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleEntity.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Article.java @@ -24,7 +24,7 @@ import java.util.ArrayList; import java.util.List; -public record ArticleEntity( +public record Article( String slug, String title, String description, @@ -37,8 +37,8 @@ public record ArticleEntity( List favoritedBy, Author author) { - public ArticleEntity(ArticleCreationDAO article, String slug, Long createdAt, Long updatedAt, - Author author) { + public Article(ArticleCreationDTO article, String slug, Long createdAt, Long 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/entity/article/ArticleCreationDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleCreationDTO.java similarity index 95% rename from examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleCreationDAO.java rename to examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleCreationDTO.java index f4c9fbcc5..dc95a943c 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleCreationDAO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleCreationDTO.java @@ -29,6 +29,6 @@ @JsonTypeName("article") @JsonTypeInfo(include = As.WRAPPER_OBJECT, use = Id.NAME) -public record ArticleCreationDAO(@NotNull String title, @NotNull String description, @NotNull String body, +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/entity/article/ArticleDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleDTO.java similarity index 96% rename from examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleDAO.java rename to examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleDTO.java index b0b29c359..d8b8c8d58 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleDAO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleDTO.java @@ -30,7 +30,7 @@ @JsonTypeName("article") @JsonTypeInfo(include = As.WRAPPER_OBJECT, use = Id.NAME) -public record ArticleDAO( +public record ArticleDTO( String slug, String title, String description, @@ -43,7 +43,7 @@ public record ArticleDAO( Author author) { - public ArticleDAO(ArticleEntity article) { + public ArticleDTO(Article article) { this(article.slug(), article.title(), article.description(), article.body(), article.tagList(), Instant.ofEpochMilli(article.createdAt()), Instant.ofEpochMilli(article.updatedAt()), article.favorited(), article.favoritesCount(), article.author()); diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleForListDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleForListDTO.java similarity index 91% rename from examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleForListDAO.java rename to examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleForListDTO.java index 92dc4be8f..0a181cd5c 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleForListDAO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleForListDTO.java @@ -24,7 +24,7 @@ import java.time.Instant; import java.util.List; -public record ArticleForListDAO( +public record ArticleForListDTO( String slug, String title, String description, @@ -37,13 +37,13 @@ public record ArticleForListDAO( Author author) { - public ArticleForListDAO(ArticleEntity article) { + public ArticleForListDTO(Article article) { this(article.slug(), article.title(), article.description(), article.body(), article.tagList(), Instant.ofEpochMilli(article.createdAt()), Instant.ofEpochMilli(article.updatedAt()), article.favorited(), article.favoritesCount(), article.author()); } - public ArticleForListDAO(ArticleForListDAO article, Author 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/entity/article/ArticleUpdateDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleUpdateDTO.java similarity index 94% rename from examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleUpdateDAO.java rename to examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleUpdateDTO.java index 5cf750875..a9c86eecf 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleUpdateDAO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleUpdateDTO.java @@ -26,5 +26,5 @@ @JsonTypeName("article") @JsonTypeInfo(include = As.WRAPPER_OBJECT, use = Id.NAME) -public record ArticleUpdateDAO(String title, String description, String body) { +public record ArticleUpdateDTO(String title, String description, String body) { } diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Articles.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticlesDTO.java similarity index 91% rename from examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Articles.java rename to examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticlesDTO.java index ac47d5633..429fdb7cd 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Articles.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticlesDTO.java @@ -21,5 +21,5 @@ import java.util.List; -public record Articles(List articles, int articlesCount) { +public record ArticlesDTO(List articles, int articlesCount) { } diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Tags.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/TagsDTO.java similarity index 95% rename from examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Tags.java rename to examples/realworld-app/rw-database/src/main/java/realworld/entity/article/TagsDTO.java index 0b282fdb9..d7b11cce4 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Tags.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/TagsDTO.java @@ -21,5 +21,5 @@ import java.util.List; -public record Tags(List tags) { +public record TagsDTO(List tags) { } diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentEntity.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/Comment.java similarity index 85% rename from examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentEntity.java rename to examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/Comment.java index 34dc3f49b..85936675c 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentEntity.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/Comment.java @@ -21,6 +21,6 @@ import realworld.entity.user.Author; -public record CommentEntity(Integer id, Long createdAt, Long updatedAt, String body, Author author, - String articleSlug) { +public record Comment(Integer id, Long createdAt, Long updatedAt, String body, Author author, + String articleSlug) { } diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentCreationDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentCreationDTO.java similarity index 95% rename from examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentCreationDAO.java rename to examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentCreationDTO.java index a5390b92f..7a80bdc14 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentCreationDAO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentCreationDTO.java @@ -27,5 +27,5 @@ @JsonTypeName("comment") @JsonTypeInfo(include = As.WRAPPER_OBJECT, use = Id.NAME) -public record CommentCreationDAO(@NotNull String body) { +public record CommentCreationDTO(@NotNull String body) { } diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentDTO.java similarity index 79% rename from examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentDAO.java rename to examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentDTO.java index 783099e6d..b7ff28656 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentDAO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentDTO.java @@ -29,11 +29,11 @@ @JsonTypeName("comment") @JsonTypeInfo(include = As.WRAPPER_OBJECT, use = Id.NAME) -public record CommentDAO(Integer id, Instant createdAt, Instant updatedAt, String body, Author author) { +public record CommentDTO(Integer id, Instant createdAt, Instant updatedAt, String body, Author author) { - public CommentDAO(CommentEntity commentEntity) { - this(commentEntity.id(), Instant.ofEpochMilli(commentEntity.createdAt()), - Instant.ofEpochMilli(commentEntity.updatedAt()), commentEntity.body(), - commentEntity.author()); + public CommentDTO(Comment comment) { + this(comment.id(), Instant.ofEpochMilli(comment.createdAt()), + Instant.ofEpochMilli(comment.updatedAt()), comment.body(), + comment.author()); } } diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentForListDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentForListDTO.java similarity index 74% rename from examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentForListDAO.java rename to examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentForListDTO.java index ef1d08118..247444d0b 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentForListDAO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentForListDTO.java @@ -23,12 +23,12 @@ import java.time.Instant; -public record CommentForListDAO(Integer id, Instant createdAt, Instant updatedAt, String body, +public record CommentForListDTO(Integer id, Instant createdAt, Instant updatedAt, String body, Author author) { - public CommentForListDAO(CommentEntity commentEntity) { - this(commentEntity.id(), Instant.ofEpochMilli(commentEntity.createdAt()), - Instant.ofEpochMilli(commentEntity.updatedAt()), commentEntity.body(), - commentEntity.author()); + public CommentForListDTO(Comment comment) { + this(comment.id(), Instant.ofEpochMilli(comment.createdAt()), + Instant.ofEpochMilli(comment.updatedAt()), comment.body(), + comment.author()); } } diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/Comments.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentsDTO.java similarity index 93% rename from examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/Comments.java rename to examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentsDTO.java index 44938818a..d2c2de79b 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/Comments.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentsDTO.java @@ -21,5 +21,5 @@ import java.util.List; -public record Comments(List comments) { +public record CommentsDTO(List comments) { } diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Author.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Author.java index 0a1104b45..f7cf4c072 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Author.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Author.java @@ -25,11 +25,11 @@ public record Author( String bio, boolean following) { - public Author(UserEntity ue, boolean following) { + public Author(User ue, boolean following) { this(ue.username(), ue.email(), ue.bio(), following); } - public Author(UserDAO ue, boolean following) { + public Author(UserDTO ue, boolean following) { this(ue.username(), ue.email(), ue.bio(), following); } } diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/LoginDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/LoginDTO.java similarity index 93% rename from examples/realworld-app/rw-database/src/main/java/realworld/entity/user/LoginDAO.java rename to examples/realworld-app/rw-database/src/main/java/realworld/entity/user/LoginDTO.java index bc3358563..fb92c31a8 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/LoginDAO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/LoginDTO.java @@ -21,5 +21,5 @@ import jakarta.validation.constraints.NotNull; -public record LoginDAO(@NotNull String email, @NotNull String password) { +public record LoginDTO(@NotNull String email, @NotNull String password) { } diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Profile.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Profile.java index 80cd357e7..1b614a8c1 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Profile.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Profile.java @@ -32,7 +32,7 @@ public record Profile( String bio, boolean following) { - public Profile(UserEntity ue, boolean following) { + public Profile(User ue, boolean following) { this(ue.username(), ue.image(), ue.bio(), following); } } diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/RegisterDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/RegisterDTO.java similarity index 93% rename from examples/realworld-app/rw-database/src/main/java/realworld/entity/user/RegisterDAO.java rename to examples/realworld-app/rw-database/src/main/java/realworld/entity/user/RegisterDTO.java index 769b2d47e..4651b58ec 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/RegisterDAO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/RegisterDTO.java @@ -21,5 +21,5 @@ import jakarta.validation.constraints.NotNull; -public record RegisterDAO(@NotNull String username, @NotNull String email, @NotNull String password) { +public record RegisterDTO(@NotNull String username, @NotNull String email, @NotNull String password) { } diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserEntity.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/User.java similarity index 97% rename from examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserEntity.java rename to examples/realworld-app/rw-database/src/main/java/realworld/entity/user/User.java index cc53c6173..022c6c516 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserEntity.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/User.java @@ -21,7 +21,7 @@ import java.util.List; -public record UserEntity( +public record User( String username, String email, diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserDAO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserDTO.java similarity index 95% rename from examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserDAO.java rename to examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserDTO.java index 91100be6c..f89ef3ea7 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserDAO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserDTO.java @@ -26,7 +26,7 @@ @JsonTypeName("user") @JsonTypeInfo(include = As.WRAPPER_OBJECT, use = Id.NAME) -public record UserDAO( +public record UserDTO( String username, String email, @@ -34,7 +34,7 @@ public record UserDAO( String bio, String image) { - public UserDAO(UserEntity ue) { + public UserDTO(User ue) { this(ue.username(), ue.email(), ue.token(), ue.bio(), ue.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..49bbb3b4b --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/utils/ArticleIdPair.java @@ -0,0 +1,6 @@ +package realworld.utils; + +import realworld.entity.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..e150adee3 --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/utils/UserIdPair.java @@ -0,0 +1,6 @@ +package realworld.utils; + +import realworld.entity.user.User; + +public record UserIdPair(User user, String id) { +} 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 index 057ced03e..e02dc1309 100644 --- 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 @@ -24,13 +24,14 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.TestPropertySource; -import realworld.entity.user.RegisterDAO; -import realworld.entity.user.UserDAO; -import realworld.entity.user.UserEntity; +import realworld.entity.user.RegisterDTO; +import realworld.entity.user.User; +import realworld.entity.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 @TestPropertySource(locations = "classpath:test.properties") @SpringBootTest(classes = {UserService.class, UserServiceTest.class, ElasticClientTest.class}) public class UserServiceTest { @@ -40,16 +41,16 @@ public class UserServiceTest { @Test public void testCreateUpdateUser() throws IOException { - RegisterDAO register = new RegisterDAO("userr", "mail", "pw"); - UserEntity result = service.newUser(register); + RegisterDTO register = new RegisterDTO("userr", "mail", "pw"); + User result = service.newUser(register); assert (result.username().equals(register.username())); assert (result.email().equals(register.email())); assert (result.password().equals(register.password())); assert (Objects.nonNull(result.token())); String token = "Token " + result.token(); - UserDAO update = new UserDAO("new-user", "mail", "", "bio", "image"); - result = service.updateUser(token, update); + 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())); 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 index e62ab9035..e5c6f0aca 100644 --- 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 @@ -26,13 +26,21 @@ import org.springframework.web.bind.annotation.*; import realworld.db.ArticleService; import realworld.db.CommentService; +import realworld.db.UserService; import realworld.entity.article.*; -import realworld.entity.comment.CommentCreationDAO; -import realworld.entity.comment.CommentDAO; -import realworld.entity.comment.CommentEntity; -import realworld.entity.comment.Comments; +import realworld.entity.comment.Comment; +import realworld.entity.comment.CommentCreationDTO; +import realworld.entity.comment.CommentDTO; +import realworld.entity.comment.CommentsDTO; +import realworld.entity.exception.ResourceNotFoundException; +import realworld.entity.user.Author; +import realworld.entity.user.User; +import realworld.utils.UserIdPair; import java.io.IOException; +import java.util.Optional; + +import static realworld.utils.Utility.isNullOrBlank; @CrossOrigin() @RestController @@ -40,104 +48,140 @@ 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, CommentService commentService) { + public ArticleController(ArticleService articleService, UserService userService, + CommentService commentService) { this.articleService = articleService; + this.userService = userService; this.commentService = commentService; } @PostMapping() - public ResponseEntity createArticle(@RequestHeader("Authorization") String auth, - @RequestBody ArticleCreationDAO req) throws IOException { - ArticleEntity res = articleService.newArticle(req, auth); + 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 ArticleDAO(res)); + return ResponseEntity.ok(new ArticleDTO(res)); } @GetMapping("/{slug}") - public ResponseEntity getArticleBySlug(@PathVariable String slug) throws IOException { - ArticleEntity res = articleService.getArticleBySlug(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 ArticleDAO(res)); + return ResponseEntity.ok(new ArticleDTO(res)); } @GetMapping() - public ResponseEntity getArticles(@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 { - Articles res = articleService.getArticles(tag, author, favorited, limit, offset, auth); + 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 getFeed(@RequestHeader("Authorization") String auth) throws IOException { - Articles res = articleService.articleFeed(auth); + 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 favoriteArticle(@RequestHeader("Authorization") String auth, - @PathVariable String slug) throws IOException { - ArticleEntity res = articleService.favoriteArticle(slug, auth); + 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 ArticleDAO(res)); + return ResponseEntity.ok(new ArticleDTO(res)); } @DeleteMapping("/{slug}/favorite") - public ResponseEntity unfavoriteArticle(@RequestHeader("Authorization") String auth, - @PathVariable String slug) throws IOException { - ArticleEntity res = articleService.unfavoriteArticle(slug, auth); + 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 ArticleDAO(res)); + return ResponseEntity.ok(new ArticleDTO(res)); } @PutMapping("/{slug}") - public ResponseEntity updateArticle(@RequestHeader("Authorization") String auth, - @RequestBody ArticleUpdateDAO req, - @PathVariable String slug) throws IOException { - ArticleDAO res = articleService.updateArticle(req, auth, 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(@RequestHeader("Authorization") String auth, - @PathVariable String slug) throws IOException { - articleService.deleteArticle(auth, 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(@RequestHeader("Authorization") String auth, - @PathVariable String slug, - @RequestBody CommentCreationDAO comment) throws IOException { - CommentEntity res = commentService.newComment(comment, slug, auth); + 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 CommentDAO(res)); + return ResponseEntity.ok(new CommentDTO(res)); } @GetMapping("/{slug}/comments") - public ResponseEntity allCommentsByArticle(@RequestHeader("Authorization") String auth, - @PathVariable String slug) throws IOException { - Comments res = commentService.allCommentsByArticle(slug, auth); + 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(@RequestHeader("Authorization") String auth, - @PathVariable String slug, @PathVariable String commentId) throws IOException { - commentService.deleteComment(commentId, slug, auth); + 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, slug, 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 index 86a9a12c6..d4bd2e08d 100644 --- 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 @@ -46,7 +46,7 @@ public ProfileController(UserService service) { @GetMapping("/{username}") public ResponseEntity get(@PathVariable String username, @RequestHeader("Authorization") String auth) throws IOException { - Profile res = service.getUserProfile(username, auth); + Profile res = service.findUserProfile(username, auth); logger.debug("Returning profile for 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 index 6bc82c9d9..babac3783 100644 --- 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 @@ -28,7 +28,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import realworld.db.ArticleService; -import realworld.entity.article.Tags; +import realworld.entity.article.TagsDTO; import java.io.IOException; @@ -47,8 +47,8 @@ public TagsController(ArticleService service) { } @GetMapping() - public ResponseEntity get() throws IOException { - Tags res = service.allTags(); + public ResponseEntity get() throws IOException { + TagsDTO res = service.allTags(); 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 index d8df47840..45a089b06 100644 --- 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 @@ -25,10 +25,10 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import realworld.db.UserService; -import realworld.entity.user.LoginDAO; -import realworld.entity.user.RegisterDAO; -import realworld.entity.user.UserDAO; -import realworld.entity.user.UserEntity; +import realworld.entity.user.LoginDTO; +import realworld.entity.user.RegisterDTO; +import realworld.entity.user.User; +import realworld.entity.user.UserDTO; import java.io.IOException; @@ -47,33 +47,33 @@ public UserController(UserService service) { } @PostMapping("/users") - public ResponseEntity register(@RequestBody RegisterDAO req) throws IOException { - UserEntity res = service.newUser(req); + public ResponseEntity register(@RequestBody RegisterDTO req) throws IOException { + User res = service.newUser(req); logger.debug("Registered new user {}", req.username()); - return ResponseEntity.ok(new UserDAO(res)); + return ResponseEntity.ok(new UserDTO(res)); } @PostMapping("users/login") - public ResponseEntity login(@RequestBody LoginDAO req) throws IOException { - UserEntity res = service.login(req); + public ResponseEntity login(@RequestBody LoginDTO req) throws IOException { + User res = service.authenticateUser(req); logger.debug("User {} logged in", res.username()); - return ResponseEntity.ok(new UserDAO(res)); + return ResponseEntity.ok(new UserDTO(res)); } @GetMapping("/user") - public ResponseEntity get(@RequestHeader("Authorization") String auth) throws IOException { - UserEntity res = service.getUserEntityFromToken(auth); + 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 UserDAO(res)); + return ResponseEntity.ok(new UserDTO(res)); } @PutMapping("/user") - public ResponseEntity update(@RequestHeader("Authorization") String auth, - @RequestBody UserDAO req) throws IOException { - UserEntity res = service.updateUser(auth, req); + 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 UserDAO(res)); + return ResponseEntity.ok(new UserDTO(res)); } } From c63afeb40d40b81fcef81c0e03439ed9202d656c Mon Sep 17 00:00:00 2001 From: Laura Date: Tue, 23 Jan 2024 16:50:07 +0100 Subject: [PATCH 14/22] quick bug fixes --- .../src/main/java/realworld/entity/user/Author.java | 10 +--------- .../src/main/java/realworld/entity/user/LoginDTO.java | 4 ++++ .../main/java/realworld/entity/user/RegisterDTO.java | 4 ++++ .../src/main/java/realworld/rest/TagsController.java | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Author.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Author.java index f7cf4c072..224fdba94 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Author.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Author.java @@ -19,17 +19,9 @@ package realworld.entity.user; -public record Author( - String username, - String email, - String bio, - boolean following) { +public record Author(String username, String email, String bio, boolean following) { public Author(User ue, boolean following) { this(ue.username(), ue.email(), ue.bio(), following); } - - public Author(UserDTO ue, boolean following) { - this(ue.username(), ue.email(), ue.bio(), following); - } } diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/LoginDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/LoginDTO.java index fb92c31a8..dbb6a40ca 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/LoginDTO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/LoginDTO.java @@ -19,7 +19,11 @@ package realworld.entity.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/entity/user/RegisterDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/RegisterDTO.java index 4651b58ec..fbf637490 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/RegisterDTO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/RegisterDTO.java @@ -19,7 +19,11 @@ package realworld.entity.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-rest/src/main/java/realworld/rest/TagsController.java b/examples/realworld-app/rw-rest/src/main/java/realworld/rest/TagsController.java index babac3783..e82ff1b68 100644 --- 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 @@ -48,7 +48,7 @@ public TagsController(ArticleService service) { @GetMapping() public ResponseEntity get() throws IOException { - TagsDTO res = service.allTags(); + TagsDTO res = service.findAllTags(); logger.debug("Retrieved all tags"); return ResponseEntity.ok(res); } From b7a861f63567cf090888ddc82fe9df1ce3e9098c Mon Sep 17 00:00:00 2001 From: Laura Date: Tue, 23 Jan 2024 17:19:55 +0100 Subject: [PATCH 15/22] putting java instant directly into db --- examples/realworld-app/build.gradle | 3 ++- .../realworld-app/rw-database/build.gradle | 22 +++++++++---------- .../java/realworld/db/ArticleService.java | 6 ++--- .../java/realworld/db/CommentService.java | 2 +- .../main/java/realworld/db/ElasticClient.java | 4 ++-- .../realworld/entity/article/Article.java | 10 ++++++--- .../realworld/entity/article/ArticleDTO.java | 5 ++++- .../entity/article/ArticleForListDTO.java | 5 ++++- .../realworld/entity/comment/Comment.java | 14 ++++++++++-- .../realworld/entity/comment/CommentDTO.java | 14 +++++++++--- .../entity/comment/CommentForListDTO.java | 15 +++++++++---- .../java/realworld/entity/user/Profile.java | 6 +---- .../main/java/realworld/entity/user/User.java | 1 - .../java/realworld/entity/user/UserDTO.java | 1 - examples/realworld-app/rw-rest/build.gradle | 5 +++-- examples/realworld-app/rw-server/build.gradle | 4 ++-- 16 files changed, 73 insertions(+), 44 deletions(-) diff --git a/examples/realworld-app/build.gradle b/examples/realworld-app/build.gradle index ab0712c01..54eb51a64 100644 --- a/examples/realworld-app/build.gradle +++ b/examples/realworld-app/build.gradle @@ -16,5 +16,6 @@ repositories { } dependencies { - implementation("org.springframework.boot:spring-boot-starter-parent:3.2.0") + implementation('org.springframework.boot:spring-boot-starter-parent:3.2.0') } + diff --git a/examples/realworld-app/rw-database/build.gradle b/examples/realworld-app/rw-database/build.gradle index de4897393..4ca6298a7 100644 --- a/examples/realworld-app/rw-database/build.gradle +++ b/examples/realworld-app/rw-database/build.gradle @@ -15,23 +15,23 @@ repositories { mavenCentral() } + dependencies { - implementation("org.springframework.boot:spring-boot-starter:3.2.0") - implementation("org.springframework.boot:spring-boot-starter-validation:3.2.0") + implementation('org.springframework.boot:spring-boot-starter:3.2.0') + implementation('org.springframework.boot:spring-boot-starter-validation:3.2.0') - implementation("co.elastic.clients:elasticsearch-java:8.11.4") - implementation("com.fasterxml.jackson.core:jackson-databind:2.12.3") - implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.16.0") + implementation('co.elastic.clients:elasticsearch-java:8.11.4') + implementation('com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.16.0') + implementation('io.jsonwebtoken:jjwt:0.9.1') - implementation("io.jsonwebtoken:jjwt:0.9.1") - implementation("javax.xml.bind:jaxb-api:2.3.1") - implementation("com.github.slugify:slugify:3.0.6") + implementation('javax.xml.bind:jaxb-api:2.3.1') + implementation('com.github.slugify:slugify:3.0.6') // MIT // https://www.testcontainers.org/ - testImplementation("org.testcontainers:testcontainers:1.17.3") - testImplementation("org.testcontainers:elasticsearch:1.17.3") - testImplementation("org.springframework.boot:spring-boot-starter-test:3.2.0") + testImplementation('org.testcontainers:testcontainers:1.17.3') + testImplementation('org.testcontainers:elasticsearch:1.17.3') + testImplementation('org.springframework.boot:spring-boot-starter-test:3.2.0') } tasks.named('test') { diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java b/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java index 12d00acb2..8272cf18e 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java @@ -73,9 +73,7 @@ public Article newArticle(ArticleCreationDTO articleDTO, Author author) throws I // Checking if slug would be unique String slug = generateAndCheckSlug(articleDTO.title()); - // Getting the author - Long now = Instant.now().toEpochMilli(); - + Instant now = Instant.now(); Article article = new Article(articleDTO, slug, now, now, author); IndexRequest
articleReq = IndexRequest.of((id -> id @@ -125,7 +123,7 @@ public ArticleDTO updateArticle(ArticleUpdateDTO article, String slug, Author au newSlug = generateAndCheckSlug(article.title()); } - Long updatedAt = Instant.now().toEpochMilli(); + Instant updatedAt = Instant.now(); Article updatedArticle = new Article(newSlug, isNullOrBlank(article.title()) ? oldArticle.title() : article.title(), 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 index 740481744..0c48b31c5 100644 --- 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 @@ -57,7 +57,7 @@ public CommentService(ElasticsearchClient esClient) { public Comment newComment(CommentCreationDTO commentDTO, String slug, User user) throws IOException { // assuming you cannot follow yourself Author commentAuthor = new Author(user, false); - Long now = Instant.now().toEpochMilli(); + Instant now = Instant.now(); // pre-generating id since it's a field in the comment class Integer commentId = UUID.randomUUID().hashCode(); 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 index 3106e007f..3c91e45ec 100644 --- 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 @@ -117,9 +117,9 @@ private void createIndexWithDateMapping(ElasticsearchClient esClient, String ind .index(index) .mappings(m -> m .properties("createdAt", p -> p - .date(d -> d)) // epoch_millis is already present by default + .date(d -> d.format("strict_date_optional_time"))) .properties("updatedAt", p -> p - .date(d -> d)))); + .date(d -> d.format("strict_date_optional_time"))))); } } diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Article.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Article.java index 21b1d7b81..57c331829 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Article.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Article.java @@ -19,8 +19,10 @@ package realworld.entity.article; +import com.fasterxml.jackson.annotation.JsonFormat; import realworld.entity.user.Author; +import java.time.Instant; import java.util.ArrayList; import java.util.List; @@ -30,14 +32,16 @@ public record Article( String description, String body, List tagList, - Long createdAt, - Long updatedAt, + @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, Long createdAt, Long updatedAt, + 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/entity/article/ArticleDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleDTO.java index d8b8c8d58..a25a91dfb 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleDTO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleDTO.java @@ -19,6 +19,7 @@ package realworld.entity.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; @@ -36,7 +37,9 @@ public record ArticleDTO( 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, @@ -45,7 +48,7 @@ public record ArticleDTO( public ArticleDTO(Article article) { this(article.slug(), article.title(), article.description(), article.body(), article.tagList(), - Instant.ofEpochMilli(article.createdAt()), Instant.ofEpochMilli(article.updatedAt()), + article.createdAt(), article.updatedAt(), article.favorited(), article.favoritesCount(), article.author()); } } diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleForListDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleForListDTO.java index 0a181cd5c..a921c4557 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleForListDTO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleForListDTO.java @@ -19,6 +19,7 @@ package realworld.entity.article; +import com.fasterxml.jackson.annotation.JsonFormat; import realworld.entity.user.Author; import java.time.Instant; @@ -30,7 +31,9 @@ public record ArticleForListDTO( 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, @@ -39,7 +42,7 @@ public record ArticleForListDTO( public ArticleForListDTO(Article article) { this(article.slug(), article.title(), article.description(), article.body(), article.tagList(), - Instant.ofEpochMilli(article.createdAt()), Instant.ofEpochMilli(article.updatedAt()), + article.createdAt(), article.updatedAt(), article.favorited(), article.favoritesCount(), article.author()); } diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/Comment.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/Comment.java index 85936675c..e18125067 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/Comment.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/Comment.java @@ -19,8 +19,18 @@ package realworld.entity.comment; +import com.fasterxml.jackson.annotation.JsonFormat; import realworld.entity.user.Author; -public record Comment(Integer id, Long createdAt, Long updatedAt, String body, Author author, - String articleSlug) { +import java.time.Instant; + +public record Comment( + Integer 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/entity/comment/CommentDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentDTO.java index b7ff28656..15cd5c837 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentDTO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentDTO.java @@ -19,6 +19,7 @@ package realworld.entity.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; @@ -29,11 +30,18 @@ @JsonTypeName("comment") @JsonTypeInfo(include = As.WRAPPER_OBJECT, use = Id.NAME) -public record CommentDTO(Integer id, Instant createdAt, Instant updatedAt, String body, Author author) { +public record CommentDTO( + Integer 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(), Instant.ofEpochMilli(comment.createdAt()), - Instant.ofEpochMilli(comment.updatedAt()), comment.body(), + this(comment.id(), comment.createdAt(), + comment.updatedAt(), comment.body(), comment.author()); } } diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentForListDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentForListDTO.java index 247444d0b..12cbd45c0 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentForListDTO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentForListDTO.java @@ -19,16 +19,23 @@ package realworld.entity.comment; +import com.fasterxml.jackson.annotation.JsonFormat; import realworld.entity.user.Author; import java.time.Instant; -public record CommentForListDTO(Integer id, Instant createdAt, Instant updatedAt, String body, - Author author) { +public record CommentForListDTO( + Integer 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(), Instant.ofEpochMilli(comment.createdAt()), - Instant.ofEpochMilli(comment.updatedAt()), comment.body(), + this(comment.id(), comment.createdAt(), + comment.updatedAt(), comment.body(), comment.author()); } } diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Profile.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Profile.java index 1b614a8c1..bdd81b574 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Profile.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Profile.java @@ -26,11 +26,7 @@ @JsonTypeName("profile") @JsonTypeInfo(include = As.WRAPPER_OBJECT, use = Id.NAME) -public record Profile( - String username, - String image, - String bio, - boolean following) { +public record Profile(String username, String image, String bio, boolean following) { public Profile(User ue, boolean following) { this(ue.username(), ue.image(), ue.bio(), following); diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/User.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/User.java index 022c6c516..6ae1d7926 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/User.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/User.java @@ -22,7 +22,6 @@ import java.util.List; public record User( - String username, String email, String password, diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserDTO.java index f89ef3ea7..1768e11b4 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserDTO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserDTO.java @@ -27,7 +27,6 @@ @JsonTypeName("user") @JsonTypeInfo(include = As.WRAPPER_OBJECT, use = Id.NAME) public record UserDTO( - String username, String email, String token, diff --git a/examples/realworld-app/rw-rest/build.gradle b/examples/realworld-app/rw-rest/build.gradle index f0aeb7b4e..8b17e2b26 100644 --- a/examples/realworld-app/rw-rest/build.gradle +++ b/examples/realworld-app/rw-rest/build.gradle @@ -16,6 +16,7 @@ repositories { } dependencies { - implementation("org.springframework.boot:spring-boot-starter-web:3.2.0") - implementation("realworldapp:rw-database") + implementation('org.springframework.boot:spring-boot-starter-web:3.2.0') + implementation('realworldapp:rw-database') } + diff --git a/examples/realworld-app/rw-server/build.gradle b/examples/realworld-app/rw-server/build.gradle index 67cea3fc9..42d7057b4 100644 --- a/examples/realworld-app/rw-server/build.gradle +++ b/examples/realworld-app/rw-server/build.gradle @@ -16,6 +16,6 @@ repositories { } dependencies { - implementation("org.springframework.boot:spring-boot-starter:3.2.0") - implementation("realworldapp:rw-rest") + implementation('org.springframework.boot:spring-boot-starter:3.2.0') + implementation('realworldapp:rw-rest') } From 4e88ece46c8f233483126043220db3d3e263fe66 Mon Sep 17 00:00:00 2001 From: Laura Date: Thu, 25 Jan 2024 16:30:56 +0100 Subject: [PATCH 16/22] logic optimizations, hashing passwords --- .../java/realworld/db/ArticleService.java | 227 +++++++++-------- .../java/realworld/db/CommentService.java | 105 ++++---- .../main/java/realworld/db/ElasticClient.java | 31 +-- .../main/java/realworld/db/UserService.java | 229 ++++++++++-------- .../realworld/entity/article/Article.java | 28 +-- .../realworld/entity/article/ArticleDTO.java | 28 +-- .../entity/article/ArticleForListDTO.java | 32 +-- .../realworld/entity/comment/Comment.java | 16 +- .../realworld/entity/comment/CommentDTO.java | 18 +- .../entity/comment/CommentForListDTO.java | 18 +- .../java/realworld/entity/user/Author.java | 4 +- .../java/realworld/entity/user/Profile.java | 4 +- .../main/java/realworld/entity/user/User.java | 15 +- .../java/realworld/entity/user/UserDTO.java | 14 +- .../java/realworld/db/ElasticClientTest.java | 40 +-- .../realworld/rest/ArticleController.java | 18 +- .../rest/error/RestExceptionHandler.java | 32 +-- 17 files changed, 431 insertions(+), 428 deletions(-) diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java b/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java index 8272cf18e..b41785700 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java @@ -24,6 +24,7 @@ import co.elastic.clients.elasticsearch._types.Refresh; import co.elastic.clients.elasticsearch._types.SortOrder; import co.elastic.clients.elasticsearch._types.aggregations.Aggregation; +import co.elastic.clients.elasticsearch._types.query_dsl.MatchQuery; import co.elastic.clients.elasticsearch._types.query_dsl.MatchQuery.Builder; import co.elastic.clients.elasticsearch._types.query_dsl.Query; import co.elastic.clients.elasticsearch._types.query_dsl.TermsQueryField; @@ -77,9 +78,9 @@ public Article newArticle(ArticleCreationDTO articleDTO, Author author) throws I Article article = new Article(articleDTO, slug, now, now, author); IndexRequest
articleReq = IndexRequest.of((id -> id - .index(ARTICLES) - .refresh(Refresh.WaitFor) - .document(article))); + .index(ARTICLES) + .refresh(Refresh.WaitFor) // TODO explain + .document(article))); esClient.index(articleReq); @@ -90,13 +91,13 @@ public ArticleIdPair findArticleBySlug(String slug) throws IOException { // using term query to match exactly the slug SearchResponse
getArticle = esClient.search(ss -> ss - .index(ARTICLES) - .query(q -> q - .term(t -> t - .field("slug.keyword") - .value(slug)) - ) - , Article.class); + .index(ARTICLES) + .query(q -> q + .term(t -> t + .field("slug.keyword") + .value(slug)) + ) + , Article.class); if (getArticle.hits().hits().isEmpty()) { return null; @@ -108,7 +109,7 @@ public ArticleDTO updateArticle(ArticleUpdateDTO article, String slug, Author au // getting original article from slug ArticleIdPair articlePair = Optional.ofNullable(findArticleBySlug(slug)) - .orElseThrow(() -> new ResourceNotFoundException("Article not found")); + .orElseThrow(() -> new ResourceNotFoundException("Article not found")); String id = articlePair.id(); Article oldArticle = articlePair.article(); @@ -126,12 +127,12 @@ public ArticleDTO updateArticle(ArticleUpdateDTO article, String slug, Author au Instant updatedAt = Instant.now(); 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()); + 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); @@ -141,7 +142,7 @@ 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")); + .orElseThrow(() -> new ResourceNotFoundException("Article not found")); Article article = articlePair.article(); // checking if author is the same @@ -151,14 +152,14 @@ public void deleteArticle(String slug, Author author) throws IOException { // the delete query is very similar to the search query DeleteByQueryResponse deleteArticle = esClient.deleteByQuery(d -> d - .index(ARTICLES) - .waitForCompletion(true) - .refresh(true) - .query(q -> q - .term(t -> t - .field("slug.keyword") - .value(slug)) - )); + .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"); } @@ -166,22 +167,19 @@ public void deleteArticle(String slug, Author author) throws IOException { // also 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)) - )); - if (deleteCommentsByArticle.deleted() < 1) { - throw new RuntimeException("Failed to delete comments after article deletion"); - } + .index(COMMENTS) + .waitForCompletion(true) + .refresh(true) + .query(q -> q + .term(t -> t + .field("articleSlug.keyword") + .value(slug)) + )); } public Article markArticleAsFavorite(String slug, String username) throws IOException { ArticleIdPair articlePair = Optional.ofNullable(findArticleBySlug(slug)) - .orElseThrow(() -> new ResourceNotFoundException("Article not found")); + .orElseThrow(() -> new ResourceNotFoundException("Article not found")); String id = articlePair.id(); Article article = articlePair.article(); @@ -192,9 +190,9 @@ public Article markArticleAsFavorite(String slug, String username) throws IOExce 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()); + article.description(), + article.body(), article.tagList(), article.createdAt(), article.updatedAt(), + true, article.favoritesCount() + 1, article.favoritedBy(), article.author()); updateArticle(id, updatedArticle); return updatedArticle; @@ -202,7 +200,7 @@ public Article markArticleAsFavorite(String slug, String username) throws IOExce public Article removeArticleFromFavorite(String slug, String username) throws IOException { ArticleIdPair articlePair = Optional.ofNullable(findArticleBySlug(slug)) - .orElseThrow(() -> new ResourceNotFoundException("Article not found")); + .orElseThrow(() -> new ResourceNotFoundException("Article not found")); String id = articlePair.id(); Article article = articlePair.article(); @@ -219,9 +217,9 @@ public Article removeArticleFromFavorite(String slug, String username) throws IO } Article updatedArticle = new Article(article.slug(), article.title(), - article.description(), - article.body(), article.tagList(), article.createdAt(), article.updatedAt(), favorited, - favoriteCount, article.favoritedBy(), article.author()); + article.description(), + article.body(), article.tagList(), article.createdAt(), article.updatedAt(), favorited, + favoriteCount, article.favoritedBy(), article.author()); updateArticle(id, updatedArticle); return updatedArticle; @@ -238,83 +236,84 @@ public ArticlesDTO findArticles(String tag, String author, String favorited, Int // food" tag if (!isNullOrBlank(tag)) { match.add(new Builder() - .field("tagList") - .query(tag).build()._toQuery()); + .field("tagList") + .query(tag).build()._toQuery()); } if (!isNullOrBlank(author)) { match.add(new Builder() - .field("author.username") - .query(author).build()._toQuery()); + .field("author.username") + .query(author).build()._toQuery()); } + // alternative syntax with builder if (!isNullOrBlank(favorited)) { - match.add(new Builder() - .field("favoritedBy") - .query(favorited).build()._toQuery()); + match.add(MatchQuery.of(mq -> mq.field("favoritedBy") + .query(favorited)) + ._toQuery()); } Query query = new Query.Builder().bool(b -> b.should(match)).build(); SearchResponse
getArticle = esClient.search(ss -> ss - .index(ARTICLES) - .size(limit) - .from(offset) - .query(query) - .sort(srt -> srt - .field(fld -> fld - .field("updatedAt") - .order(SortOrder.Desc))) - , Article.class); + .index(ARTICLES) + .size(limit) + .from(offset) + .query(query) + .sort(srt -> srt + .field(fld -> fld + .field("updatedAt") + .order(SortOrder.Desc))) + , Article.class); 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 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()); + .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 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()); } public ArticlesDTO generateArticleFeed(User user) throws IOException { // preparing authors filter from user data List authorsFilter = user.following().stream() - .map(FieldValue::of).toList(); + .map(FieldValue::of).toList(); // a terms query can be used to query for multiple values, like authors. // the sort options is used afterward to determine which field determines the output order // note how the nested class "author" is easily accessible with the use of the dot notation 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); + .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) - .collect(Collectors.toList()), articlesByAuthors.hits().hits().size()); + .stream() + .map(Hit::source) + .map(ArticleForListDTO::new) + .collect(Collectors.toList()), articlesByAuthors.hits().hits().size()); } @@ -327,21 +326,21 @@ public TagsDTO findAllTags() throws IOException { // using a term aggregation is the simplest way to find every distinct tag for each article SearchResponse aggregateTags = esClient.search(s -> s - .index(ARTICLES) - .size(0) // this is to only return aggregation result, and not also search result - .aggregations("tags", agg -> agg - .terms(ter -> ter - .field("tagList.keyword") - .order(sort)) - ), - Aggregation.class + .index(ARTICLES) + .size(0) // this is to only return aggregation result, and not also search result + .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()) + .sterms().buckets() + .array().stream() + .map(st -> st.key().stringValue()) + .collect(Collectors.toList()) ); } @@ -355,10 +354,10 @@ private String generateAndCheckSlug(String title) throws IOException { private void updateArticle(String id, Article updatedArticle) throws IOException { UpdateResponse
upArticle = esClient.update(up -> up - .index(ARTICLES) - .id(id) - .doc(updatedArticle) - , Article.class); + .index(ARTICLES) + .id(id) + .doc(updatedArticle) + , Article.class); if (!upArticle.result().name().equals("Updated")) { throw new RuntimeException("Article update failed"); } 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 index 0c48b31c5..01a781ee7 100644 --- 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 @@ -30,19 +30,16 @@ import realworld.entity.comment.CommentCreationDTO; import realworld.entity.comment.CommentForListDTO; import realworld.entity.comment.CommentsDTO; -import realworld.entity.exception.ResourceNotFoundException; -import realworld.entity.exception.UnauthorizedException; import realworld.entity.user.Author; import realworld.entity.user.User; import java.io.IOException; +import java.security.SecureRandom; import java.time.Instant; import java.util.Optional; -import java.util.UUID; import java.util.stream.Collectors; import static realworld.constant.Constants.COMMENTS; -import static realworld.utils.Utility.extractSource; @Service public class CommentService { @@ -60,14 +57,14 @@ public Comment newComment(CommentCreationDTO commentDTO, String slug, User user) Instant now = Instant.now(); // pre-generating id since it's a field in the comment class - Integer commentId = UUID.randomUUID().hashCode(); + Long commentId = new SecureRandom().nextLong(); Comment comment = new Comment(commentId, now, now, commentDTO.body(), commentAuthor, - slug); + slug); IndexRequest commentReq = IndexRequest.of((id -> id - .index(COMMENTS) - .refresh(Refresh.WaitFor) - .document(comment))); + .index(COMMENTS) + .refresh(Refresh.WaitFor) + .document(comment))); esClient.index(commentReq); @@ -75,70 +72,50 @@ public Comment newComment(CommentCreationDTO commentDTO, String slug, User user) return comment; } - public void deleteComment(String commentId, String slug, String username) throws IOException { - - // getting comment by id - // using term query to match exactly the id - SearchResponse getComment = esClient.search(ss -> ss - .index(COMMENTS) - .query(q -> q - .term(t -> t - .field("id") - .value(commentId)) - ) - , Comment.class); - - if (getComment.hits().hits().isEmpty()) { - throw new ResourceNotFoundException("Comment not found"); - } - Comment comment = extractSource(getComment); - - // checking if the comment is from the same author - if (!username.equals(comment.author().username())) { - throw new UnauthorizedException("Cannot delete someone else's comment"); - } - - // checking that the slug matches the one received - if (!extractSource(getComment).articleSlug().equals(slug)) { - throw new RuntimeException("Incorrect article slug"); - } + public void deleteComment(String commentId, String username) throws IOException { - // deleting comment by id + // deleting comment, finding only comments with same id and same author username DeleteByQueryResponse deleteComment = esClient.deleteByQuery(ss -> ss - .index(COMMENTS) - .waitForCompletion(true) - .refresh(true) - .query(q -> q - .term(t -> t - .field("id") - .value(commentId)) - )); + .index(COMMENTS) + .waitForCompletion(true) + .refresh(true) + .query(q -> q + .bool(b -> b + .must(m -> m + .term(mc -> mc + .field("id") + .value(commentId)) + ).must(m -> m + .term(mc -> mc + .field("author.username.keyword") + .value(username)))) + )); if (deleteComment.deleted() < 1) { - throw new RuntimeException("Failed to delete article"); + throw new RuntimeException("Failed to delete comment"); } } 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); + .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())); + .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 index 3c91e45ec..b5aefea27 100644 --- 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 @@ -58,19 +58,20 @@ 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(); + .builder(HttpHost.create(serverUrl)) + .setDefaultHeaders(new Header[]{ + new BasicHeader("Authorization", "ApiKey " + apiKey) + }) + .build(); + // TODO explain ObjectMapper mapper = JsonMapper.builder() - .addModule(new JavaTimeModule()) // other modules can be added here - .build(); + .addModule(new JavaTimeModule()) // other modules can be added here + .build(); // Create the transport with a Jackson mapper ElasticsearchTransport transport = new RestClientTransport( - restClient, new JacksonJsonpMapper(mapper)); + restClient, new JacksonJsonpMapper(mapper)); // And create the API client ElasticsearchClient esClient = new ElasticsearchClient(transport); @@ -95,7 +96,7 @@ private void createSimpleIndex(ElasticsearchClient esClient, String index) throw BooleanResponse indexRes = esClient.indices().exists(ex -> ex.index(index)); if (!indexRes.value()) { esClient.indices().create(c -> c - .index(index)); + .index(index)); } } @@ -114,12 +115,12 @@ private void createIndexWithDateMapping(ElasticsearchClient esClient, String ind 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"))))); + .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 index 1c16c780a..44f6ef9e3 100644 --- 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 @@ -37,12 +37,15 @@ import realworld.entity.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.ArrayList; -import java.util.Date; -import java.util.Objects; -import java.util.Optional; +import java.util.*; import static realworld.constant.Constants.USERS; import static realworld.utils.Utility.*; @@ -78,61 +81,67 @@ public UserService(ElasticsearchClient esClient) { public User newUser(RegisterDTO user) throws IOException { SearchResponse checkUser = esClient.search(ss -> ss - .index(USERS) - .query(q -> q - .bool(b -> b - .should(m -> m - .term(mc -> mc - .field("email.keyword") - .value(user.email())) - ).should(m -> m - .term(mc -> mc - .field("username.keyword") - .value(user.username()))))) - , User.class); + .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"); - }); + .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 + .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(); - - User ue = new User(user.username(), user.email(), - user.password(), jws, "", "", new ArrayList<>()); + .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(ue))); + .index(USERS) + .refresh(Refresh.WaitFor) + .document(newUser))); // indexing the request (inserting it into to database) esClient.index(userReq); - return ue; + return newUser; } /** @@ -142,28 +151,29 @@ public User newUser(RegisterDTO user) throws IOException { * * @return The authenticated user. */ - public User authenticateUser(LoginDTO user) throws IOException { + public User authenticateUser(LoginDTO login) throws IOException { SearchResponse getUser = esClient.search(ss -> ss - .index(USERS) - .query(q -> q - .bool(b -> b - .must(m -> m - .term(mc -> mc - .field("email.keyword") - .value(user.email())) - ).must(m -> m - .term(mc -> mc - .field("password.keyword") - .value(user.password())))) - ) - , User.class); + .index(USERS) + .query(q -> q + .term(t -> t + .field("email.keyword") + .value(login.email()))) + , User.class); + if (getUser.hits().hits().isEmpty()) { - throw new ResourceNotFoundException("Wrong email or password"); + throw new ResourceNotFoundException("Email not found"); } - return extractSource(getUser); + // 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; } /** @@ -178,20 +188,20 @@ public UserIdPair findUserByToken(String auth) throws IOException { try { token = auth.split(" ")[1]; Jwts.parser() - .setSigningKey(TextCodec.BASE64.decode(jwtSigningKey)) - .parse(token); + .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(m -> m - .field("token.keyword") - .value(token)) - ) - , User.class); + .index(USERS) + .query(q -> q + .term(m -> m + .field("token.keyword") + .value(token)) + ) + , User.class); if (getUser.hits().hits().isEmpty()) { throw new ResourceNotFoundException("Token not assigned to any user"); @@ -227,28 +237,28 @@ public User updateUser(UserDTO userDTO, String auth) throws IOException { } // null/blank check for every optional field - User ue = 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.following()); - - updateUser(userPair.id(), ue); - return ue; + 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 ue) throws IOException { + private void updateUser(String id, User user) throws IOException { UpdateResponse upUser = esClient.update(up -> up - .index(USERS) - .id(id) - .doc(ue) - , User.class); + .index(USERS) + .id(id) + .doc(user) + , User.class); if (!upUser.result().name().equals("Updated")) { throw new RuntimeException("User update failed"); } @@ -257,7 +267,7 @@ private void updateUser(String id, User ue) throws IOException { public Profile findUserProfile(String username, String auth) throws IOException { UserIdPair targetUserPair = Optional.ofNullable(findUserByUsername(username)) - .orElseThrow(() -> new ResourceNotFoundException("User not found")); + .orElseThrow(() -> new ResourceNotFoundException("User not found")); User targetUser = targetUserPair.user(); // checking if the user is followed by who's asking @@ -270,7 +280,7 @@ public Profile findUserProfile(String username, String auth) throws IOException public Profile followUser(String username, String auth) throws IOException { UserIdPair targetUserPair = Optional.ofNullable(findUserByUsername(username)) - .orElseThrow(() -> new ResourceNotFoundException("User not found")); + .orElseThrow(() -> new ResourceNotFoundException("User not found")); User targetUser = targetUserPair.user(); UserIdPair askingUserPair = findUserByToken(auth); @@ -292,7 +302,7 @@ public Profile followUser(String username, String auth) throws IOException { public Profile unfollowUser(String username, String auth) throws IOException { UserIdPair targetUserPair = Optional.ofNullable(findUserByUsername(username)) - .orElseThrow(() -> new ResourceNotFoundException("User not found")); + .orElseThrow(() -> new ResourceNotFoundException("User not found")); User targetUser = targetUserPair.user(); UserIdPair askingUserPair = findUserByToken(auth); @@ -325,12 +335,12 @@ public Profile unfollowUser(String username, String auth) throws IOException { private 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); + .index(USERS) + .query(q -> q + .term(t -> t + .field("username.keyword") + .value(username))) + , User.class); if (getUser.hits().hits().isEmpty()) { return null; } @@ -346,15 +356,30 @@ private UserIdPair findUserByUsername(String username) throws IOException { 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); + .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)); } + + 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/entity/article/Article.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Article.java index 57c331829..7a15065ae 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Article.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Article.java @@ -27,23 +27,23 @@ 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) { + 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); + updatedAt, false, 0, new ArrayList<>(), author); } } diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleDTO.java index a25a91dfb..0e6b11479 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleDTO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleDTO.java @@ -32,23 +32,23 @@ @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) { + 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()); + article.createdAt(), article.updatedAt(), + article.favorited(), article.favoritesCount(), article.author()); } } diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleForListDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleForListDTO.java index a921c4557..65ec5b065 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleForListDTO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleForListDTO.java @@ -26,29 +26,29 @@ 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) { + 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()); + 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); + article.createdAt(), article.updatedAt(), article.favorited(), article.favoritesCount(), + author); } } diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/Comment.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/Comment.java index e18125067..13904893e 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/Comment.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/Comment.java @@ -25,12 +25,12 @@ import java.time.Instant; public record Comment( - Integer 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) { + 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/entity/comment/CommentDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentDTO.java index 15cd5c837..1425aaf65 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentDTO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentDTO.java @@ -31,17 +31,17 @@ @JsonTypeName("comment") @JsonTypeInfo(include = As.WRAPPER_OBJECT, use = Id.NAME) public record CommentDTO( - Integer 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) { + 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()); + comment.updatedAt(), comment.body(), + comment.author()); } } diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentForListDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentForListDTO.java index 12cbd45c0..27f37cfcb 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentForListDTO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentForListDTO.java @@ -25,17 +25,17 @@ import java.time.Instant; public record CommentForListDTO( - Integer 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) { + 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()); + comment.updatedAt(), comment.body(), + comment.author()); } } diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Author.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Author.java index 224fdba94..616f45c8b 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Author.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Author.java @@ -21,7 +21,7 @@ public record Author(String username, String email, String bio, boolean following) { - public Author(User ue, boolean following) { - this(ue.username(), ue.email(), ue.bio(), 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/entity/user/Profile.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Profile.java index bdd81b574..e64d05d89 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Profile.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Profile.java @@ -28,7 +28,7 @@ @JsonTypeInfo(include = As.WRAPPER_OBJECT, use = Id.NAME) public record Profile(String username, String image, String bio, boolean following) { - public Profile(User ue, boolean following) { - this(ue.username(), ue.image(), ue.bio(), 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/entity/user/User.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/User.java index 6ae1d7926..2dd6b67f1 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/User.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/User.java @@ -22,11 +22,12 @@ import java.util.List; public record User( - String username, - String email, - String password, - String token, - String bio, - String image, - List following) { + 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/entity/user/UserDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserDTO.java index 1768e11b4..328a20709 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserDTO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserDTO.java @@ -27,13 +27,13 @@ @JsonTypeName("user") @JsonTypeInfo(include = As.WRAPPER_OBJECT, use = Id.NAME) public record UserDTO( - String username, - String email, - String token, - String bio, - String image) { + String username, + String email, + String token, + String bio, + String image) { - public UserDTO(User ue) { - this(ue.username(), ue.email(), ue.token(), ue.bio(), ue.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/test/java/realworld/db/ElasticClientTest.java b/examples/realworld-app/rw-database/src/test/java/realworld/db/ElasticClientTest.java index c0061b1aa..967c97361 100644 --- 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 @@ -50,10 +50,10 @@ public class ElasticClientTest { public ElasticsearchClient elasticRestClient() throws IOException { 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"); + .withEnv("ES_JAVA_OPTS", "-Xms256m -Xmx256m") + .withEnv("path.repo", "/tmp") // for snapshots + .withStartupTimeout(Duration.ofSeconds(30)) + .withPassword("changeme"); container.start(); int port = container.getMappedPort(9200); @@ -64,19 +64,19 @@ public ElasticsearchClient elasticRestClient() throws IOException { BasicCredentialsProvider credsProv = new BasicCredentialsProvider(); credsProv.setCredentials( - AuthScope.ANY, new UsernamePasswordCredentials("elastic", "changeme") + AuthScope.ANY, new UsernamePasswordCredentials("elastic", "changeme") ); RestClient restClient = RestClient.builder(host) - .setHttpClientConfigCallback(hc -> hc - .setDefaultCredentialsProvider(credsProv) - .setSSLContext(sslContext) - ) - .build(); + .setHttpClientConfigCallback(hc -> hc + .setDefaultCredentialsProvider(credsProv) + .setSSLContext(sslContext) + ) + .build(); ObjectMapper mapper = JsonMapper.builder() - .addModule(new JavaTimeModule()) // other modules can be added here - .build(); + .addModule(new JavaTimeModule()) // other modules can be added here + .build(); ElasticsearchTransport transport = new RestClientTransport(restClient, - new JacksonJsonpMapper(mapper)); + new JacksonJsonpMapper(mapper)); ElasticsearchClient esClient = new ElasticsearchClient(transport); // Creating the indexes @@ -91,7 +91,7 @@ private void createSimpleIndex(ElasticsearchClient esClient, String index) throw BooleanResponse indexRes = esClient.indices().exists(ex -> ex.index(index)); if (!indexRes.value()) { esClient.indices().create(c -> c - .index(index)); + .index(index)); } } @@ -99,12 +99,12 @@ private void createIndexWithDateMapping(ElasticsearchClient esClient, String ind 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)))); + .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-rest/src/main/java/realworld/rest/ArticleController.java b/examples/realworld-app/rw-rest/src/main/java/realworld/rest/ArticleController.java index e5c6f0aca..ff2f476b3 100644 --- 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 @@ -63,7 +63,7 @@ public ArticleController(ArticleService articleService, UserService userService, @PostMapping() public ResponseEntity newArticle(@RequestBody ArticleCreationDTO req, @RequestHeader( - "Authorization") String auth) throws IOException { + "Authorization") String auth) throws IOException { UserIdPair userPair = userService.findUserByToken(auth); Author author = new Author(userPair.user(), false); @@ -75,8 +75,8 @@ public ResponseEntity newArticle(@RequestBody ArticleCreationDTO req @GetMapping("/{slug}") public ResponseEntity findArticleBySlug(@PathVariable String slug) throws IOException { Article res = Optional.ofNullable(articleService.findArticleBySlug(slug)) - .orElseThrow(() -> new ResourceNotFoundException("Article not found")) - .article(); + .orElseThrow(() -> new ResourceNotFoundException("Article not found")) + .article(); logger.debug("Retrieved article: {}", slug); return ResponseEntity.ok(new ArticleDTO(res)); } @@ -88,7 +88,7 @@ public ResponseEntity findArticles(@RequestParam(required = false) @RequestParam(required = false) Integer limit, @RequestParam(required = false) Integer offset, @RequestHeader(value = "Authorization", required = - false) String auth) throws IOException { + false) String auth) throws IOException { Optional user = Optional.empty(); if (!isNullOrBlank(auth)) { user = Optional.of(userService.findUserByToken(auth).user()); @@ -109,7 +109,7 @@ public ResponseEntity generateFeed(@RequestHeader("Authorization") @PostMapping("/{slug}/favorite") public ResponseEntity markArticleAsFavorite(@PathVariable String slug, @RequestHeader( - "Authorization") String auth) throws IOException { + "Authorization") String auth) throws IOException { String username = userService.findUserByToken(auth).user().username(); Article res = articleService.markArticleAsFavorite(slug, username); @@ -119,7 +119,7 @@ public ResponseEntity markArticleAsFavorite(@PathVariable String slu @DeleteMapping("/{slug}/favorite") public ResponseEntity removeArticleFromFavorite(@PathVariable String slug, @RequestHeader( - "Authorization") String auth) throws IOException { + "Authorization") String auth) throws IOException { String username = userService.findUserByToken(auth).user().username(); Article res = articleService.removeArticleFromFavorite(slug, username); @@ -130,7 +130,7 @@ public ResponseEntity removeArticleFromFavorite(@PathVariable String @PutMapping("/{slug}") public ResponseEntity updateArticle(@RequestBody ArticleUpdateDTO req, @PathVariable String slug, @RequestHeader( - "Authorization") String auth) throws IOException { + "Authorization") String auth) throws IOException { UserIdPair userPair = userService.findUserByToken(auth); Author author = new Author(userPair.user(), false); @@ -166,7 +166,7 @@ public ResponseEntity commentArticle(@PathVariable String slug, @GetMapping("/{slug}/comments") public ResponseEntity allCommentsByArticle(@PathVariable String slug, @RequestHeader( - value = "Authorization", required = false) String auth) throws IOException { + value = "Authorization", required = false) String auth) throws IOException { Optional user = Optional.empty(); if (!isNullOrBlank(auth)) { user = Optional.of(userService.findUserByToken(auth).user()); @@ -181,7 +181,7 @@ public ResponseEntity deleteComment(@PathVariable String slug, @PathVariab @RequestHeader("Authorization") String auth) throws IOException { String username = userService.findUserByToken(auth).user().username(); - commentService.deleteComment(commentId, slug, 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/error/RestExceptionHandler.java b/examples/realworld-app/rw-rest/src/main/java/realworld/rest/error/RestExceptionHandler.java index 186b65359..6f8695db4 100644 --- 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 @@ -35,45 +35,45 @@ @ControllerAdvice public class RestExceptionHandler - extends ResponseEntityExceptionHandler { + extends ResponseEntityExceptionHandler { @ExceptionHandler(value - = {IOException.class}) + = {IOException.class}) protected ResponseEntity handleIo( - RuntimeException ex, WebRequest request) { + RuntimeException ex, WebRequest request) { return handleExceptionInternal(ex, new RestError(List.of("Connection Error with the Database")), - new HttpHeaders(), HttpStatus.INTERNAL_SERVER_ERROR, request); + new HttpHeaders(), HttpStatus.INTERNAL_SERVER_ERROR, request); } @ExceptionHandler(value - = {ResourceAlreadyExistsException.class}) + = {ResourceAlreadyExistsException.class}) protected ResponseEntity handleConflict( - RuntimeException ex, WebRequest request) { + RuntimeException ex, WebRequest request) { return handleExceptionInternal(ex, new RestError(List.of(ex.getLocalizedMessage())), - new HttpHeaders(), HttpStatus.CONFLICT, request); + new HttpHeaders(), HttpStatus.CONFLICT, request); } @ExceptionHandler(value - = {ResourceNotFoundException.class}) + = {ResourceNotFoundException.class}) protected ResponseEntity handleNotFound( - RuntimeException ex, WebRequest request) { + RuntimeException ex, WebRequest request) { return handleExceptionInternal(ex, new RestError(List.of(ex.getLocalizedMessage())), - new HttpHeaders(), HttpStatus.NOT_FOUND, request); + new HttpHeaders(), HttpStatus.NOT_FOUND, request); } @ExceptionHandler(value - = {UnauthorizedException.class}) + = {UnauthorizedException.class}) protected ResponseEntity handleUnauthorized( - RuntimeException ex, WebRequest request) { + RuntimeException ex, WebRequest request) { return handleExceptionInternal(ex, new RestError(List.of(ex.getLocalizedMessage())), - new HttpHeaders(), HttpStatus.UNAUTHORIZED, request); + new HttpHeaders(), HttpStatus.UNAUTHORIZED, request); } @ExceptionHandler(value - = {RuntimeException.class}) + = {RuntimeException.class}) protected ResponseEntity handleUnexpected( - RuntimeException ex, WebRequest request) { + RuntimeException ex, WebRequest request) { return handleExceptionInternal(ex, new RestError(List.of(ex.getLocalizedMessage())), - new HttpHeaders(), HttpStatus.INTERNAL_SERVER_ERROR, request); + new HttpHeaders(), HttpStatus.INTERNAL_SERVER_ERROR, request); } } From 69ffb710849544b112a376c649e07cae85944dbd Mon Sep 17 00:00:00 2001 From: Laura Date: Thu, 25 Jan 2024 16:37:23 +0100 Subject: [PATCH 17/22] fixed unit test --- .../src/test/java/realworld/db/UserServiceTest.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 index e02dc1309..da4a18013 100644 --- 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 @@ -24,6 +24,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.TestPropertySource; +import realworld.entity.user.LoginDTO; import realworld.entity.user.RegisterDTO; import realworld.entity.user.User; import realworld.entity.user.UserDTO; @@ -45,10 +46,13 @@ public void testCreateUpdateUser() throws IOException { User result = service.newUser(register); assert (result.username().equals(register.username())); assert (result.email().equals(register.email())); - assert (result.password().equals(register.password())); 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())); From 5d721ce21ad9a07b387b4c3a341c1f1eb2dcc24b Mon Sep 17 00:00:00 2001 From: Laura Date: Thu, 25 Jan 2024 18:07:29 +0100 Subject: [PATCH 18/22] more documentation --- .../java/realworld/db/ArticleService.java | 122 +++++++++++++----- .../java/realworld/db/CommentService.java | 16 ++- .../main/java/realworld/db/ElasticClient.java | 13 +- .../main/java/realworld/db/UserService.java | 27 ++-- 4 files changed, 124 insertions(+), 54 deletions(-) diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java b/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java index b41785700..6a3d0882a 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java @@ -66,8 +66,18 @@ public ArticleService(ElasticsearchClient esClient) { /** * Creates a new article and saves it into the articles index. + *

+ * 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 {@link Article} + * @return the new article. */ public Article newArticle(ArticleCreationDTO articleDTO, Author author) throws IOException { @@ -79,7 +89,7 @@ public Article newArticle(ArticleCreationDTO articleDTO, Author author) throws I IndexRequest

articleReq = IndexRequest.of((id -> id .index(ARTICLES) - .refresh(Refresh.WaitFor) // TODO explain + .refresh(Refresh.WaitFor) .document(article))); esClient.index(articleReq); @@ -87,9 +97,14 @@ public Article newArticle(ArticleCreationDTO articleDTO, Author author) throws I 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 { - // using term query to match exactly the slug SearchResponse
getArticle = esClient.search(ss -> ss .index(ARTICLES) .query(q -> q @@ -105,6 +120,13 @@ public ArticleIdPair findArticleBySlug(String slug) throws IOException { 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 @@ -138,6 +160,33 @@ public ArticleDTO updateArticle(ArticleUpdateDTO article, String slug, Author au 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 again + * set as "wait_for" for both delete queries, since the frontend will again perform a get operation + * immediately after. + */ public void deleteArticle(String slug, Author author) throws IOException { // getting article from slug @@ -150,7 +199,6 @@ public void deleteArticle(String slug, Author author) throws IOException { throw new UnauthorizedException("Cannot delete article from another author"); } - // the delete query is very similar to the search query DeleteByQueryResponse deleteArticle = esClient.deleteByQuery(d -> d .index(ARTICLES) .waitForCompletion(true) @@ -164,8 +212,8 @@ public void deleteArticle(String slug, Author author) throws IOException { throw new RuntimeException("Failed to delete article"); } - // also delete every comment to the article, using a term query that will match all comments with - // the same articleSlug + // 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) @@ -183,7 +231,7 @@ public Article markArticleAsFavorite(String slug, String username) throws IOExce String id = articlePair.id(); Article article = articlePair.article(); - // checking if article was already favorited + // checking if article was already marked as favorite if (article.favoritedBy().contains(username)) { return article; } @@ -225,45 +273,60 @@ public Article removeArticleFromFavorite(String slug, String username) throws IO 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 match = new ArrayList<>(); - // since all the parameters for this query are optional, the query must be build conditionally - // using a "match" query instead of a "term" query to allow the use a single word for searching - // phrases - // for example, filtering for articles with the "cat" tag will also return articles with the "cat - // food" tag + List conditions = new ArrayList<>(); + if (!isNullOrBlank(tag)) { - match.add(new Builder() + conditions.add(new Builder() .field("tagList") .query(tag).build()._toQuery()); } if (!isNullOrBlank(author)) { - match.add(new Builder() + conditions.add(new Builder() .field("author.username") .query(author).build()._toQuery()); } - // alternative syntax with builder + // alternative equivalent syntax to build the match query without using the Builder explicitly if (!isNullOrBlank(favorited)) { - match.add(MatchQuery.of(mq -> mq.field("favoritedBy") + conditions.add(MatchQuery.of(mq -> mq + .field("favoritedBy") .query(favorited)) ._toQuery()); } - Query query = new Query.Builder().bool(b -> b.should(match)).build(); + Query query = new Query.Builder().bool(b -> b.should(conditions)).build(); SearchResponse

getArticle = esClient.search(ss -> ss .index(ARTICLES) - .size(limit) - .from(offset) + .size(limit) // how many results to return + .from(offset) // starting point .query(query) .sort(srt -> srt .field(fld -> fld .field("updatedAt") - .order(SortOrder.Desc))) + .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) @@ -274,7 +337,7 @@ public ArticlesDTO findArticles(String tag, String author, String favorited, Int } }) .map(ArticleForListDTO::new) - // if auth provided, filling the "following" field of "Author" accordingly + // 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()); @@ -286,6 +349,10 @@ public ArticlesDTO findArticles(String tag, String author, String favorited, Int .collect(Collectors.toList()), getArticle.hits().hits().size()); } + /** + * TODO resume here + * @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() @@ -352,15 +419,4 @@ private String generateAndCheckSlug(String title) throws IOException { return slug; } - 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"); - } - } - } 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 index 01a781ee7..7db332475 100644 --- 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 @@ -31,6 +31,7 @@ import realworld.entity.comment.CommentForListDTO; import realworld.entity.comment.CommentsDTO; import realworld.entity.user.Author; +import realworld.entity.user.RegisterDTO; import realworld.entity.user.User; import java.io.IOException; @@ -72,9 +73,18 @@ public Comment newComment(CommentCreationDTO commentDTO, String slug, User user) 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 { - // deleting comment, finding only comments with same id and same author username DeleteByQueryResponse deleteComment = esClient.deleteByQuery(ss -> ss .index(COMMENTS) .waitForCompletion(true) @@ -82,11 +92,11 @@ public void deleteComment(String commentId, String username) throws IOException .query(q -> q .bool(b -> b .must(m -> m - .term(mc -> mc + .term(t -> t .field("id") .value(commentId)) ).must(m -> m - .term(mc -> mc + .term(t -> t .field("author.username.keyword") .value(username)))) )); 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 index b5aefea27..e9270c701 100644 --- 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 @@ -64,16 +64,20 @@ public ElasticsearchClient elasticRestClient() throws IOException { }) .build(); - // TODO explain + // 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()) // other modules can be added here + .addModule(new JavaTimeModule()) .build(); - // Create the transport with a Jackson mapper + // Create the transport with the Jackson mapper ElasticsearchTransport transport = new RestClientTransport( restClient, new JacksonJsonpMapper(mapper)); - // And create the API client + // Create the API client ElasticsearchClient esClient = new ElasticsearchClient(transport); // Creating the indexes @@ -82,7 +86,6 @@ public ElasticsearchClient elasticRestClient() throws IOException { createIndexWithDateMapping(esClient, COMMENTS); return esClient; - } /** 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 index 44f6ef9e3..c63642137 100644 --- 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 @@ -64,15 +64,15 @@ public UserService(ElasticsearchClient esClient) { } /** - * Inserts a new Userinto the "users" index, checking beforehand whether the username and email + * 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. - *
+ *

* 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. * @@ -145,9 +145,9 @@ public User newUser(RegisterDTO user) throws IOException { } /** - * To identify a user based on their email and passoword, a boolean query similar to the one used in - * {@link UserService#newUser(RegisterDTO)} is used, with a difference: here "must" is used instead of - * "should", meaning that the documents must match both conditions at the same time. + * 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. */ @@ -197,7 +197,7 @@ public UserIdPair findUserByToken(String auth) throws IOException { SearchResponse getUser = esClient.search(ss -> ss .index(USERS) .query(q -> q - .term(m -> m + .term(t -> t .field("token.keyword") .value(token)) ) @@ -211,8 +211,8 @@ public UserIdPair findUserByToken(String auth) throws IOException { /** * See {@link UserService#updateUser(String, User)} - *
- * Updated a user, checking before if the new username or email would be unique. + *

+ * Updates a user, checking before if the new username or email would be unique. * * @return the updated user. */ @@ -321,10 +321,11 @@ public Profile unfollowUser(String username, String auth) throws IOException { /** * 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. - *
+ * 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 From ed58fdb9024e5a98ec2bc3ae6ebc21757d356f13 Mon Sep 17 00:00:00 2001 From: Laura Date: Fri, 26 Jan 2024 17:41:59 +0100 Subject: [PATCH 19/22] finished documenting methods, added README.md, set up gradle correctly, minor refactors --- examples/realworld-app/README.md | 77 +++++++++++++- examples/realworld-app/build.gradle | 4 + examples/realworld-app/docker-compose.yaml | 29 ++++++ .../realworld-app/rw-database/build.gradle | 8 +- .../java/realworld/db/ArticleService.java | 95 ++++++++++++------ .../java/realworld/db/CommentService.java | 28 ++++-- .../main/java/realworld/db/UserService.java | 63 ++++++++---- .../{entity => document}/article/Article.java | 4 +- .../article/ArticleCreationDTO.java | 2 +- .../article/ArticleDTO.java | 4 +- .../article/ArticleForListDTO.java | 4 +- .../article/ArticleUpdateDTO.java | 2 +- .../article/ArticlesDTO.java | 2 +- .../{entity => document}/article/TagsDTO.java | 2 +- .../{entity => document}/comment/Comment.java | 4 +- .../comment/CommentCreationDTO.java | 2 +- .../comment/CommentDTO.java | 4 +- .../comment/CommentForListDTO.java | 4 +- .../comment/CommentsDTO.java | 2 +- .../ResourceAlreadyExistsException.java | 2 +- .../exception/ResourceNotFoundException.java | 2 +- .../exception/UnauthorizedException.java | 2 +- .../{entity => document}/user/Author.java | 2 +- .../{entity => document}/user/LoginDTO.java | 2 +- .../{entity => document}/user/Profile.java | 2 +- .../user/RegisterDTO.java | 2 +- .../{entity => document}/user/User.java | 2 +- .../{entity => document}/user/UserDTO.java | 2 +- .../java/realworld/utils/ArticleIdPair.java | 2 +- .../main/java/realworld/utils/UserIdPair.java | 2 +- .../java/realworld/db/ElasticClientTest.java | 8 +- .../java/realworld/db/UserServiceTest.java | 13 +-- examples/realworld-app/rw-logo.png | Bin 0 -> 59699 bytes .../realworld/rest/ArticleController.java | 20 ++-- .../realworld/rest/ProfileController.java | 2 +- .../java/realworld/rest/TagsController.java | 2 +- .../java/realworld/rest/UserController.java | 8 +- .../rest/error/RestExceptionHandler.java | 6 +- 38 files changed, 302 insertions(+), 119 deletions(-) create mode 100644 examples/realworld-app/docker-compose.yaml rename examples/realworld-app/rw-database/src/main/java/realworld/{entity => document}/article/Article.java (95%) rename examples/realworld-app/rw-database/src/main/java/realworld/{entity => document}/article/ArticleCreationDTO.java (97%) rename examples/realworld-app/rw-database/src/main/java/realworld/{entity => document}/article/ArticleDTO.java (96%) rename examples/realworld-app/rw-database/src/main/java/realworld/{entity => document}/article/ArticleForListDTO.java (96%) rename examples/realworld-app/rw-database/src/main/java/realworld/{entity => document}/article/ArticleUpdateDTO.java (97%) rename examples/realworld-app/rw-database/src/main/java/realworld/{entity => document}/article/ArticlesDTO.java (96%) rename examples/realworld-app/rw-database/src/main/java/realworld/{entity => document}/article/TagsDTO.java (96%) rename examples/realworld-app/rw-database/src/main/java/realworld/{entity => document}/comment/Comment.java (94%) rename examples/realworld-app/rw-database/src/main/java/realworld/{entity => document}/comment/CommentCreationDTO.java (97%) rename examples/realworld-app/rw-database/src/main/java/realworld/{entity => document}/comment/CommentDTO.java (95%) rename examples/realworld-app/rw-database/src/main/java/realworld/{entity => document}/comment/CommentForListDTO.java (94%) rename examples/realworld-app/rw-database/src/main/java/realworld/{entity => document}/comment/CommentsDTO.java (96%) rename examples/realworld-app/rw-database/src/main/java/realworld/{entity => document}/exception/ResourceAlreadyExistsException.java (96%) rename examples/realworld-app/rw-database/src/main/java/realworld/{entity => document}/exception/ResourceNotFoundException.java (96%) rename examples/realworld-app/rw-database/src/main/java/realworld/{entity => document}/exception/UnauthorizedException.java (96%) rename examples/realworld-app/rw-database/src/main/java/realworld/{entity => document}/user/Author.java (96%) rename examples/realworld-app/rw-database/src/main/java/realworld/{entity => document}/user/LoginDTO.java (97%) rename examples/realworld-app/rw-database/src/main/java/realworld/{entity => document}/user/Profile.java (97%) rename examples/realworld-app/rw-database/src/main/java/realworld/{entity => document}/user/RegisterDTO.java (97%) rename examples/realworld-app/rw-database/src/main/java/realworld/{entity => document}/user/User.java (96%) rename examples/realworld-app/rw-database/src/main/java/realworld/{entity => document}/user/UserDTO.java (97%) create mode 100644 examples/realworld-app/rw-logo.png diff --git a/examples/realworld-app/README.md b/examples/realworld-app/README.md index 6c77a3e51..244e4b647 100644 --- a/examples/realworld-app/README.md +++ b/examples/realworld-app/README.md @@ -1,3 +1,76 @@ -# realworldapp-test +# ![RealWorld Example App](rw-logo.png) -first test for a realworldapp backend using springboot and elasticsearch +> ### Spring Boot + Elasticsearch codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the [RealWorld](https://github.com/gothinkster/realworld) spec and API + +### [RealWorld](https://github.com/gothinkster/realworld) + +This codebase was created to demonstrate a fully fledged fullstack application built with **Java + Spring Boot + +Elasticsearch** including CRUD operations, authentication, routing, pagination, and more. + +We've gone to great lengths to adhere to the **Java + Spring Boot + Elasticsearch** community styleguides & best +practices. + +For more information on how to this works with other frontends/backends, head over to +the [RealWorld](https://github.com/gothinkster/realworld) repo. + +# How it works + +The application was made mainly demonstrate the +new [Elasticsearch Java API Client](https://www.elastic.co/guide/en/elasticsearch/client/java-api-client/current/index.html). + +The application was built with: + +- [Java](https://www.java.com/en/) as programming language +- [Spring Boot](https://spring.io/projects/spring-boot) as dependency injection framework +- [Gradle](https://github.com/gradle/gradle) as build tool +- [Elasticsearch](https://github.com/elastic/elasticsearch) as database +- [Jackson](https://github.com/FasterXML/jackson-core) as data bind serialization/deserialization +- [Java JWT](https://github.com/jwtk/jjwt) for JWT implementation +- [Jaxb](https://github.com/jakartaee/jaxb-api) for JWT parsing +- [Slugify](https://github.com/slugify/slugify) for slug + +Tests: + +- [Junit](https://github.com/junit-team/junit4) +- [Testcontainers](https://github.com/testcontainers) to create an Elasticsearch instance + +#### Structure + +This is a multimodule gradle project: + +- rw-database + - Elasticsearch client connection, queries, document classes +- rw-rest + - Spring rest controllers +- rw-server + - Configuration and entrypoint. Main class: SpringBootApp.java + +# Getting started + +#### JVM + +A version of the JVM has to be installed, openjdk version "21.0.2" was used during development. + +#### Elasticsearch + +An Elasticsearch instance needs to be running for the application to start successfully. +To start one easily, a [docker-compose](docker-compose.yaml) is provided, it will start Elasticsearch on port 9200 and +Kibana on [5601](http://localhost:5601/app/home#/). + +### Build: + +> ./gradlew clean build + +#### Start the server: + +> ./gradlew run + +#### Run + +The application will start on [8080](http://localhost:8080/api) with `api` context, it can be changed +in [application.properties](rw-server/src/main/resources/application.properties). + +### Unit tests + +A basic [unit test](rw-database/src/test/java/realworld/db/UserServiceTest.java) using testcontainer (docker is +required). diff --git a/examples/realworld-app/build.gradle b/examples/realworld-app/build.gradle index 54eb51a64..263b3d313 100644 --- a/examples/realworld-app/build.gradle +++ b/examples/realworld-app/build.gradle @@ -2,6 +2,7 @@ plugins { id 'java' id 'org.springframework.boot' version '3.2.1' id 'io.spring.dependency-management' version '1.1.4' + id 'application' } group = 'realworldapp' @@ -17,5 +18,8 @@ repositories { dependencies { implementation('org.springframework.boot:spring-boot-starter-parent:3.2.0') + implementation('realworldapp:rw-server') } +mainClassName = 'rw-server/src/main/java/realworld/SpringBootApp.java' + diff --git a/examples/realworld-app/docker-compose.yaml b/examples/realworld-app/docker-compose.yaml new file mode 100644 index 000000000..e07806c64 --- /dev/null +++ b/examples/realworld-app/docker-compose.yaml @@ -0,0 +1,29 @@ +services: + es: + image: docker.elastic.co/elasticsearch/elasticsearch:8.11.1 + container_name: es + environment: + "discovery.type": "single-node" + "xpack.security.enabled": "false" + "xpack.security.http.ssl.enabled": "false" + ports: + - "9200:9200" + networks: + - elastic + kibana: + image: docker.elastic.co/kibana/kibana:8.7.1 + container_name: kibana + environment: + XPACK_ENCRYPTEDSAVEDOBJECTS_ENCRYPTIONKEY: d1a66dfd-c4d3-4a0a-8290-2abcb83ab3aa + ports: + - 5601:5601 + networks: + - elastic + deploy: + resources: + limits: + cpus: '2.0' + reservations: + cpus: '1.0' +networks: + elastic: diff --git a/examples/realworld-app/rw-database/build.gradle b/examples/realworld-app/rw-database/build.gradle index 4ca6298a7..5fc84db67 100644 --- a/examples/realworld-app/rw-database/build.gradle +++ b/examples/realworld-app/rw-database/build.gradle @@ -17,20 +17,26 @@ repositories { dependencies { + // Spring implementation('org.springframework.boot:spring-boot-starter:3.2.0') implementation('org.springframework.boot:spring-boot-starter-validation:3.2.0') + // Elastic implementation('co.elastic.clients:elasticsearch-java:8.11.4') implementation('com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.16.0') - implementation('io.jsonwebtoken:jjwt:0.9.1') + // JWT creation + parsing implementation('javax.xml.bind:jaxb-api:2.3.1') + implementation('io.jsonwebtoken:jjwt:0.9.1') + + // Slug implementation('com.github.slugify:slugify:3.0.6') // MIT // https://www.testcontainers.org/ testImplementation('org.testcontainers:testcontainers:1.17.3') testImplementation('org.testcontainers:elasticsearch:1.17.3') + testImplementation('org.springframework.boot:spring-boot-starter-test:3.2.0') } diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java b/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java index 6a3d0882a..2b92e7bee 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java @@ -37,12 +37,12 @@ import com.github.slugify.Slugify; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; -import realworld.entity.article.*; -import realworld.entity.exception.ResourceAlreadyExistsException; -import realworld.entity.exception.ResourceNotFoundException; -import realworld.entity.exception.UnauthorizedException; -import realworld.entity.user.Author; -import realworld.entity.user.User; +import realworld.document.article.*; +import realworld.document.exception.ResourceAlreadyExistsException; +import realworld.document.exception.ResourceNotFoundException; +import realworld.document.exception.UnauthorizedException; +import realworld.document.user.Author; +import realworld.document.user.User; import realworld.utils.ArticleIdPair; import java.io.IOException; @@ -129,25 +129,26 @@ public ArticleIdPair findArticleBySlug(String slug) throws IOException { */ public ArticleDTO updateArticle(ArticleUpdateDTO article, String slug, Author author) throws IOException { - // getting original article from slug + // 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 + // 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 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(), @@ -183,18 +184,19 @@ private void updateArticle(String id, Article updatedArticle) throws IOException * 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 again - * set as "wait_for" for both delete queries, since the frontend will again perform a get operation - * immediately after. + * 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 + // 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 + // Checking if author is the same if (!article.author().username().equals(author.username())) { throw new UnauthorizedException("Cannot delete article from another author"); } @@ -212,7 +214,7 @@ public void deleteArticle(String slug, Author author) throws IOException { throw new RuntimeException("Failed to delete article"); } - // delete every comment to the article, using a term query + // 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) @@ -225,13 +227,18 @@ public void deleteArticle(String slug, Author author) throws IOException { )); } + /** + * 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 + // Checking if article was already marked as favorite if (article.favoritedBy().contains(username)) { return article; } @@ -246,13 +253,18 @@ public Article markArticleAsFavorite(String slug, String username) throws IOExce 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 + // Checking if article was not marked as favorite before if (!article.favoritedBy().contains(username)) { return article; } @@ -305,7 +317,7 @@ public ArticlesDTO findArticles(String tag, String author, String favorited, Int .field("author.username") .query(author).build()._toQuery()); } - // alternative equivalent syntax to build the match query without using the Builder explicitly + // Alternative equivalent syntax to build the match query without using the Builder explicitly if (!isNullOrBlank(favorited)) { conditions.add(MatchQuery.of(mq -> mq .field("favoritedBy") @@ -326,18 +338,18 @@ public ArticlesDTO findArticles(String tag, String author, String favorited, Int .order(SortOrder.Desc))) // last updated first , Article.class); - // making the output adhere to the API specification + // 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 + // 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 + // 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()); @@ -350,17 +362,25 @@ public ArticlesDTO findArticles(String tag, String author, String favorited, Int } /** - * TODO resume here + * 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 + // Preparing authors filter from user data List authorsFilter = user.following().stream() .map(FieldValue::of).toList(); - // a terms query can be used to query for multiple values, like authors. - // the sort options is used afterward to determine which field determines the output order - // note how the nested class "author" is easily accessible with the use of the dot notation SearchResponse

articlesByAuthors = esClient.search(ss -> ss .index(ARTICLES) .query(q -> q @@ -384,17 +404,25 @@ public ArticlesDTO generateArticleFeed(User user) throws IOException { } + /** + * 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 { - // since the API definition doesn't specify the return order of tags, sorting by document count - // using "_count" - // if alphabetical order is preferred, use "_key" instead + // If alphabetical order is preferred, use "_key" instead NamedValue sort = new NamedValue<>("_count", SortOrder.Desc); - // using a term aggregation is the simplest way to find every distinct tag for each article SearchResponse aggregateTags = esClient.search(s -> s .index(ARTICLES) - .size(0) // this is to only return aggregation result, and not also search result + .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") @@ -411,6 +439,11 @@ public TagsDTO findAllTags() throws IOException { ); } + /** + * 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))) { 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 index 7db332475..13ac7ae11 100644 --- 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 @@ -26,13 +26,14 @@ import co.elastic.clients.elasticsearch.core.SearchResponse; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; -import realworld.entity.comment.Comment; -import realworld.entity.comment.CommentCreationDTO; -import realworld.entity.comment.CommentForListDTO; -import realworld.entity.comment.CommentsDTO; -import realworld.entity.user.Author; -import realworld.entity.user.RegisterDTO; -import realworld.entity.user.User; +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; @@ -52,6 +53,13 @@ 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); @@ -105,6 +113,12 @@ public void deleteComment(String commentId, String username) throws IOException } } + /** + * 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) 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 index c63642137..b35ae4225 100644 --- 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 @@ -31,10 +31,10 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import realworld.entity.exception.ResourceAlreadyExistsException; -import realworld.entity.exception.ResourceNotFoundException; -import realworld.entity.exception.UnauthorizedException; -import realworld.entity.user.*; +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; @@ -71,7 +71,7 @@ public UserService(ElasticsearchClient esClient) { *

* Combining multiple term queries into a single * boolean query with "should" occur - * to match documents fulfilling either conditions. + * 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. @@ -80,6 +80,7 @@ public UserService(ElasticsearchClient esClient) { */ 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 @@ -110,7 +111,7 @@ public User newUser(RegisterDTO user) throws IOException { throw new ResourceAlreadyExistsException("Email already used"); }); - // building user's JWT, with no expiration since it's not requested + // Building user's JWT, with no expiration since it's not requested String jws = Jwts.builder() .setIssuer("rw-backend") .setSubject(user.email()) @@ -123,7 +124,7 @@ public User newUser(RegisterDTO user) throws IOException { ) .compact(); - // hashing the password, storing the salt with the user + // Hashing the password, storing the salt with the user SecureRandom secureRandom = new SecureRandom(); byte[] salt = new byte[16]; secureRandom.nextBytes(salt); @@ -132,13 +133,13 @@ public User newUser(RegisterDTO user) throws IOException { User newUser = new User(user.username(), user.email(), hashedPw, jws, "", "", salt, new ArrayList<>()); - // creating the index request + // 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) + // Indexing the request (inserting it into to database) esClient.index(userReq); return newUser; @@ -166,7 +167,7 @@ public User authenticateUser(LoginDTO login) throws IOException { throw new ResourceNotFoundException("Email not found"); } - // check if the hashed password matches the one provided + // Check if the hashed password matches the one provided User user = extractSource(getUser); String hashedPw = hashUserPw(login.password(), user.salt()); @@ -177,8 +178,8 @@ public User authenticateUser(LoginDTO login) throws IOException { } /** - * Deserializing and checking the token, then performing a term query (see - * {@link UserService#findUserByUsername(String)} for details) using the token string to retrieve + * 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. @@ -221,7 +222,7 @@ 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 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)) { @@ -236,7 +237,7 @@ public User updateUser(UserDTO userDTO, String auth) throws IOException { } } - // null/blank check for every optional field + // 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(), @@ -264,19 +265,29 @@ private void updateUser(String id, User user) throws IOException { } } + /** + * 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 + // 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)) @@ -290,7 +301,7 @@ public Profile followUser(String username, String auth) throws IOException { throw new RuntimeException("Cannot follow yourself!"); } - // add followed user to list if not already present + // Add followed user to list if not already present if (!askingUser.following().contains(targetUser.username())) { askingUser.following().add(targetUser.username()); @@ -300,6 +311,11 @@ public Profile followUser(String username, String auth) throws IOException { 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")); @@ -308,7 +324,7 @@ public Profile unfollowUser(String username, String auth) throws IOException { UserIdPair askingUserPair = findUserByToken(auth); User askingUser = askingUserPair.user(); - // remove followed user to list if not already present + // Remove followed user to list if not already present if (askingUser.following().contains(targetUser.username())) { askingUser.following().remove(targetUser.username()); @@ -333,8 +349,8 @@ public Profile unfollowUser(String username, String auth) throws IOException { * * @return a pair containing the result of the term query, a single user, with its id. */ - private UserIdPair findUserByUsername(String username) throws IOException { - // simple term query to match exactly the username string + 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 @@ -352,10 +368,10 @@ private UserIdPair findUserByUsername(String username) throws IOException { * 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. + * @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 + // Simple term query to match exactly the email string SearchResponse getUser = esClient.search(ss -> ss .index(USERS) .query(q -> q @@ -369,6 +385,11 @@ private UserIdPair findUserByEmail(String email) throws IOException { 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); diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Article.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/article/Article.java similarity index 95% rename from examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Article.java rename to examples/realworld-app/rw-database/src/main/java/realworld/document/article/Article.java index 7a15065ae..b06213bb4 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/Article.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/article/Article.java @@ -17,10 +17,10 @@ * under the License. */ -package realworld.entity.article; +package realworld.document.article; import com.fasterxml.jackson.annotation.JsonFormat; -import realworld.entity.user.Author; +import realworld.document.user.Author; import java.time.Instant; import java.util.ArrayList; diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleCreationDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/article/ArticleCreationDTO.java similarity index 97% rename from examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleCreationDTO.java rename to examples/realworld-app/rw-database/src/main/java/realworld/document/article/ArticleCreationDTO.java index dc95a943c..94d23084a 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleCreationDTO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/article/ArticleCreationDTO.java @@ -17,7 +17,7 @@ * under the License. */ -package realworld.entity.article; +package realworld.document.article; import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.annotation.JsonTypeInfo.As; diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/article/ArticleDTO.java similarity index 96% rename from examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleDTO.java rename to examples/realworld-app/rw-database/src/main/java/realworld/document/article/ArticleDTO.java index 0e6b11479..94796110e 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleDTO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/article/ArticleDTO.java @@ -17,14 +17,14 @@ * under the License. */ -package realworld.entity.article; +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.entity.user.Author; +import realworld.document.user.Author; import java.time.Instant; import java.util.List; diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleForListDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/article/ArticleForListDTO.java similarity index 96% rename from examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleForListDTO.java rename to examples/realworld-app/rw-database/src/main/java/realworld/document/article/ArticleForListDTO.java index 65ec5b065..a602ac426 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleForListDTO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/article/ArticleForListDTO.java @@ -17,10 +17,10 @@ * under the License. */ -package realworld.entity.article; +package realworld.document.article; import com.fasterxml.jackson.annotation.JsonFormat; -import realworld.entity.user.Author; +import realworld.document.user.Author; import java.time.Instant; import java.util.List; diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleUpdateDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/article/ArticleUpdateDTO.java similarity index 97% rename from examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleUpdateDTO.java rename to examples/realworld-app/rw-database/src/main/java/realworld/document/article/ArticleUpdateDTO.java index a9c86eecf..3cbcb0b3e 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticleUpdateDTO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/article/ArticleUpdateDTO.java @@ -17,7 +17,7 @@ * under the License. */ -package realworld.entity.article; +package realworld.document.article; import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.annotation.JsonTypeInfo.As; diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticlesDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/article/ArticlesDTO.java similarity index 96% rename from examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticlesDTO.java rename to examples/realworld-app/rw-database/src/main/java/realworld/document/article/ArticlesDTO.java index 429fdb7cd..46411646c 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/ArticlesDTO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/article/ArticlesDTO.java @@ -17,7 +17,7 @@ * under the License. */ -package realworld.entity.article; +package realworld.document.article; import java.util.List; diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/TagsDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/article/TagsDTO.java similarity index 96% rename from examples/realworld-app/rw-database/src/main/java/realworld/entity/article/TagsDTO.java rename to examples/realworld-app/rw-database/src/main/java/realworld/document/article/TagsDTO.java index d7b11cce4..72c3edc2b 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/article/TagsDTO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/article/TagsDTO.java @@ -17,7 +17,7 @@ * under the License. */ -package realworld.entity.article; +package realworld.document.article; import java.util.List; diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/Comment.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/comment/Comment.java similarity index 94% rename from examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/Comment.java rename to examples/realworld-app/rw-database/src/main/java/realworld/document/comment/Comment.java index 13904893e..682c1ecd1 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/Comment.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/comment/Comment.java @@ -17,10 +17,10 @@ * under the License. */ -package realworld.entity.comment; +package realworld.document.comment; import com.fasterxml.jackson.annotation.JsonFormat; -import realworld.entity.user.Author; +import realworld.document.user.Author; import java.time.Instant; diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentCreationDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/comment/CommentCreationDTO.java similarity index 97% rename from examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentCreationDTO.java rename to examples/realworld-app/rw-database/src/main/java/realworld/document/comment/CommentCreationDTO.java index 7a80bdc14..0cca6a407 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentCreationDTO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/comment/CommentCreationDTO.java @@ -17,7 +17,7 @@ * under the License. */ -package realworld.entity.comment; +package realworld.document.comment; import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.annotation.JsonTypeInfo.As; diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/comment/CommentDTO.java similarity index 95% rename from examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentDTO.java rename to examples/realworld-app/rw-database/src/main/java/realworld/document/comment/CommentDTO.java index 1425aaf65..b18db813e 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentDTO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/comment/CommentDTO.java @@ -17,14 +17,14 @@ * under the License. */ -package realworld.entity.comment; +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.entity.user.Author; +import realworld.document.user.Author; import java.time.Instant; diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentForListDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/comment/CommentForListDTO.java similarity index 94% rename from examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentForListDTO.java rename to examples/realworld-app/rw-database/src/main/java/realworld/document/comment/CommentForListDTO.java index 27f37cfcb..f5111a2fc 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentForListDTO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/comment/CommentForListDTO.java @@ -17,10 +17,10 @@ * under the License. */ -package realworld.entity.comment; +package realworld.document.comment; import com.fasterxml.jackson.annotation.JsonFormat; -import realworld.entity.user.Author; +import realworld.document.user.Author; import java.time.Instant; diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentsDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/comment/CommentsDTO.java similarity index 96% rename from examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentsDTO.java rename to examples/realworld-app/rw-database/src/main/java/realworld/document/comment/CommentsDTO.java index d2c2de79b..80bc5d802 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/comment/CommentsDTO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/comment/CommentsDTO.java @@ -17,7 +17,7 @@ * under the License. */ -package realworld.entity.comment; +package realworld.document.comment; import java.util.List; diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/exception/ResourceAlreadyExistsException.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/exception/ResourceAlreadyExistsException.java similarity index 96% rename from examples/realworld-app/rw-database/src/main/java/realworld/entity/exception/ResourceAlreadyExistsException.java rename to examples/realworld-app/rw-database/src/main/java/realworld/document/exception/ResourceAlreadyExistsException.java index 34f5de2d1..ebf0e497f 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/exception/ResourceAlreadyExistsException.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/exception/ResourceAlreadyExistsException.java @@ -17,7 +17,7 @@ * under the License. */ -package realworld.entity.exception; +package realworld.document.exception; public class ResourceAlreadyExistsException extends RuntimeException { diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/exception/ResourceNotFoundException.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/exception/ResourceNotFoundException.java similarity index 96% rename from examples/realworld-app/rw-database/src/main/java/realworld/entity/exception/ResourceNotFoundException.java rename to examples/realworld-app/rw-database/src/main/java/realworld/document/exception/ResourceNotFoundException.java index aaf08ac45..b861cc55e 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/exception/ResourceNotFoundException.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/exception/ResourceNotFoundException.java @@ -17,7 +17,7 @@ * under the License. */ -package realworld.entity.exception; +package realworld.document.exception; public class ResourceNotFoundException extends RuntimeException { diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/exception/UnauthorizedException.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/exception/UnauthorizedException.java similarity index 96% rename from examples/realworld-app/rw-database/src/main/java/realworld/entity/exception/UnauthorizedException.java rename to examples/realworld-app/rw-database/src/main/java/realworld/document/exception/UnauthorizedException.java index 6abb572d0..d94f896d4 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/exception/UnauthorizedException.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/exception/UnauthorizedException.java @@ -17,7 +17,7 @@ * under the License. */ -package realworld.entity.exception; +package realworld.document.exception; public class UnauthorizedException extends RuntimeException { diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Author.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/user/Author.java similarity index 96% rename from examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Author.java rename to examples/realworld-app/rw-database/src/main/java/realworld/document/user/Author.java index 616f45c8b..b2bf8afa5 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Author.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/user/Author.java @@ -17,7 +17,7 @@ * under the License. */ -package realworld.entity.user; +package realworld.document.user; public record Author(String username, String email, String bio, boolean following) { diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/LoginDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/user/LoginDTO.java similarity index 97% rename from examples/realworld-app/rw-database/src/main/java/realworld/entity/user/LoginDTO.java rename to examples/realworld-app/rw-database/src/main/java/realworld/document/user/LoginDTO.java index dbb6a40ca..300748b77 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/LoginDTO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/user/LoginDTO.java @@ -17,7 +17,7 @@ * under the License. */ -package realworld.entity.user; +package realworld.document.user; import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.annotation.JsonTypeName; diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Profile.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/user/Profile.java similarity index 97% rename from examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Profile.java rename to examples/realworld-app/rw-database/src/main/java/realworld/document/user/Profile.java index e64d05d89..a62704f4a 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/Profile.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/user/Profile.java @@ -17,7 +17,7 @@ * under the License. */ -package realworld.entity.user; +package realworld.document.user; import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.annotation.JsonTypeInfo.As; diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/RegisterDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/user/RegisterDTO.java similarity index 97% rename from examples/realworld-app/rw-database/src/main/java/realworld/entity/user/RegisterDTO.java rename to examples/realworld-app/rw-database/src/main/java/realworld/document/user/RegisterDTO.java index fbf637490..96cae013b 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/RegisterDTO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/user/RegisterDTO.java @@ -17,7 +17,7 @@ * under the License. */ -package realworld.entity.user; +package realworld.document.user; import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.annotation.JsonTypeName; diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/User.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/user/User.java similarity index 96% rename from examples/realworld-app/rw-database/src/main/java/realworld/entity/user/User.java rename to examples/realworld-app/rw-database/src/main/java/realworld/document/user/User.java index 2dd6b67f1..bbcb967f7 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/User.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/user/User.java @@ -17,7 +17,7 @@ * under the License. */ -package realworld.entity.user; +package realworld.document.user; import java.util.List; diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/user/UserDTO.java similarity index 97% rename from examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserDTO.java rename to examples/realworld-app/rw-database/src/main/java/realworld/document/user/UserDTO.java index 328a20709..92ff08939 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/entity/user/UserDTO.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/user/UserDTO.java @@ -17,7 +17,7 @@ * under the License. */ -package realworld.entity.user; +package realworld.document.user; import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.annotation.JsonTypeInfo.As; 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 index 49bbb3b4b..d82bd60d6 100644 --- 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 @@ -1,6 +1,6 @@ package realworld.utils; -import realworld.entity.article.Article; +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 index e150adee3..6eca40136 100644 --- 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 @@ -1,6 +1,6 @@ package realworld.utils; -import realworld.entity.user.User; +import realworld.document.user.User; public record UserIdPair(User user, String id) { } 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 index 967c97361..fb4940ac4 100644 --- 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 @@ -48,6 +48,7 @@ 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") @@ -56,16 +57,17 @@ public ElasticsearchClient elasticRestClient() throws IOException { .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) @@ -73,7 +75,7 @@ AuthScope.ANY, new UsernamePasswordCredentials("elastic", "changeme") ) .build(); ObjectMapper mapper = JsonMapper.builder() - .addModule(new JavaTimeModule()) // other modules can be added here + .addModule(new JavaTimeModule()) .build(); ElasticsearchTransport transport = new RestClientTransport(restClient, new JacksonJsonpMapper(mapper)); 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 index da4a18013..dde02c678 100644 --- 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 @@ -24,15 +24,16 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.TestPropertySource; -import realworld.entity.user.LoginDTO; -import realworld.entity.user.RegisterDTO; -import realworld.entity.user.User; -import realworld.entity.user.UserDTO; +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 { @@ -42,14 +43,14 @@ public class UserServiceTest { @Test public void testCreateUpdateUser() throws IOException { - RegisterDTO register = new RegisterDTO("userr", "mail", "pw"); + 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"); + LoginDTO login = new LoginDTO("mail", "pw"); result = service.authenticateUser(login); assert (result.username().equals(register.username())); diff --git a/examples/realworld-app/rw-logo.png b/examples/realworld-app/rw-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..99e5ed76a1ae52b33a94ed96791231bff5cfc98c GIT binary patch literal 59699 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 literal 0 HcmV?d00001 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 index ff2f476b3..3db47164e 100644 --- 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 @@ -27,14 +27,14 @@ import realworld.db.ArticleService; import realworld.db.CommentService; import realworld.db.UserService; -import realworld.entity.article.*; -import realworld.entity.comment.Comment; -import realworld.entity.comment.CommentCreationDTO; -import realworld.entity.comment.CommentDTO; -import realworld.entity.comment.CommentsDTO; -import realworld.entity.exception.ResourceNotFoundException; -import realworld.entity.user.Author; -import realworld.entity.user.User; +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; @@ -154,9 +154,9 @@ public ResponseEntity deleteArticle(@PathVariable String slug, public ResponseEntity commentArticle(@PathVariable String slug, @RequestBody CommentCreationDTO comment, @RequestHeader("Authorization") String auth) throws IOException { - // checking if the article exists + // Checking if the article exists articleService.findArticleBySlug(slug); - // getting the comment's author + // Getting the comment's author User user = userService.findUserByToken(auth).user(); Comment res = commentService.newComment(comment, slug, user); 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 index d4bd2e08d..6996ac3c6 100644 --- 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 @@ -25,7 +25,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import realworld.db.UserService; -import realworld.entity.user.Profile; +import realworld.document.user.Profile; import java.io.IOException; 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 index e82ff1b68..099386439 100644 --- 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 @@ -28,7 +28,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import realworld.db.ArticleService; -import realworld.entity.article.TagsDTO; +import realworld.document.article.TagsDTO; import java.io.IOException; 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 index 45a089b06..4338fbcf0 100644 --- 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 @@ -25,10 +25,10 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import realworld.db.UserService; -import realworld.entity.user.LoginDTO; -import realworld.entity.user.RegisterDTO; -import realworld.entity.user.User; -import realworld.entity.user.UserDTO; +import realworld.document.user.LoginDTO; +import realworld.document.user.RegisterDTO; +import realworld.document.user.User; +import realworld.document.user.UserDTO; import java.io.IOException; 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 index 6f8695db4..b09b505e2 100644 --- 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 @@ -26,9 +26,9 @@ 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.entity.exception.ResourceAlreadyExistsException; -import realworld.entity.exception.ResourceNotFoundException; -import realworld.entity.exception.UnauthorizedException; +import realworld.document.exception.ResourceAlreadyExistsException; +import realworld.document.exception.ResourceNotFoundException; +import realworld.document.exception.UnauthorizedException; import java.io.IOException; import java.util.List; From 0956aa9031e95264e565606a84ad5ba3701b28d9 Mon Sep 17 00:00:00 2001 From: Laura Date: Fri, 26 Jan 2024 17:50:29 +0100 Subject: [PATCH 20/22] fixed readme, added license to all files --- examples/realworld-app/README.md | 2 +- .../java/realworld/utils/ArticleIdPair.java | 19 +++++++++++++++++++ .../main/java/realworld/utils/UserIdPair.java | 19 +++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/examples/realworld-app/README.md b/examples/realworld-app/README.md index 244e4b647..b793c3c13 100644 --- a/examples/realworld-app/README.md +++ b/examples/realworld-app/README.md @@ -43,7 +43,7 @@ This is a multimodule gradle project: - rw-rest - Spring rest controllers - rw-server - - Configuration and entrypoint. Main class: SpringBootApp.java + - Configuration and entrypoint. Main class: [SpringBootApp.java](rw-server/src/main/java/realworld/SpringBootApp.java) # Getting started 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 index d82bd60d6..81f4f113d 100644 --- 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 @@ -1,3 +1,22 @@ +/* + * 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; 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 index 6eca40136..d8ffb1ecc 100644 --- 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 @@ -1,3 +1,22 @@ +/* + * 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; From 7c53b8625bcf8776c3d98f24d8b5c96b3fbce3ae Mon Sep 17 00:00:00 2001 From: Laura Date: Fri, 26 Jan 2024 17:54:28 +0100 Subject: [PATCH 21/22] fixed readme --- examples/realworld-app/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/realworld-app/README.md b/examples/realworld-app/README.md index b793c3c13..d8de99d20 100644 --- a/examples/realworld-app/README.md +++ b/examples/realworld-app/README.md @@ -55,7 +55,7 @@ A version of the JVM has to be installed, openjdk version "21.0.2" was used duri An Elasticsearch instance needs to be running for the application to start successfully. To start one easily, a [docker-compose](docker-compose.yaml) is provided, it will start Elasticsearch on port 9200 and -Kibana on [5601](http://localhost:5601/app/home#/). +Kibana on [5601](http://localhost:5601/app/home#/); otherwise, the connection properties can be changed in [application.properties](rw-server/src/main/resources/application.properties). ### Build: From 06eafebd8f7c3dee1fa9d257e6cb5ca34a7fee4f Mon Sep 17 00:00:00 2001 From: Laura Date: Tue, 30 Jan 2024 12:31:10 +0100 Subject: [PATCH 22/22] feed follow fix, shortened comment id --- .../src/main/java/realworld/db/ArticleService.java | 6 ++++++ .../src/main/java/realworld/db/CommentService.java | 2 +- .../src/main/java/realworld/rest/ArticleController.java | 2 +- .../src/main/java/realworld/rest/ProfileController.java | 2 +- .../src/main/java/realworld/rest/TagsController.java | 2 +- .../src/main/java/realworld/rest/UserController.java | 2 +- 6 files changed, 11 insertions(+), 5 deletions(-) diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java b/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java index 2b92e7bee..083366e7e 100644 --- a/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java +++ b/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java @@ -400,6 +400,12 @@ public ArticlesDTO generateArticleFeed(User user) throws IOException { .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()); } 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 index 13ac7ae11..24b87b8da 100644 --- 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 @@ -66,7 +66,7 @@ public Comment newComment(CommentCreationDTO commentDTO, String slug, User user) Instant now = Instant.now(); // pre-generating id since it's a field in the comment class - Long commentId = new SecureRandom().nextLong(); + Long commentId = Long.valueOf(String.valueOf(new SecureRandom().nextLong()).substring(0, 15)); Comment comment = new Comment(commentId, now, now, commentDTO.body(), commentAuthor, slug); 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 index 3db47164e..9e207c7e2 100644 --- 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 @@ -42,7 +42,7 @@ import static realworld.utils.Utility.isNullOrBlank; -@CrossOrigin() +@CrossOrigin @RestController @RequestMapping("/articles") public class ArticleController { 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 index 6996ac3c6..65ad334ca 100644 --- 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 @@ -29,7 +29,7 @@ import java.io.IOException; -@CrossOrigin() +@CrossOrigin @RestController @RequestMapping("/profiles") public class ProfileController { 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 index 099386439..7fd8452d7 100644 --- 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 @@ -32,7 +32,7 @@ import java.io.IOException; -@CrossOrigin() +@CrossOrigin @RestController @RequestMapping("/tags") public class TagsController { 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 index 4338fbcf0..2d6e7def5 100644 --- 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 @@ -32,7 +32,7 @@ import java.io.IOException; -@CrossOrigin() +@CrossOrigin @RestController @RequestMapping() public class UserController {