diff --git a/examples/realworld-app/README.md b/examples/realworld-app/README.md new file mode 100644 index 000000000..d8de99d20 --- /dev/null +++ b/examples/realworld-app/README.md @@ -0,0 +1,76 @@ +# ![RealWorld Example App](rw-logo.png) + +> ### 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](rw-server/src/main/java/realworld/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#/); otherwise, the connection properties can be changed in [application.properties](rw-server/src/main/resources/application.properties). + +### 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 new file mode 100644 index 000000000..263b3d313 --- /dev/null +++ b/examples/realworld-app/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' + id 'application' +} + +group = 'realworldapp' +version = '0.0.1-SNAPSHOT' + +java { + sourceCompatibility = '21' +} + +repositories { + mavenCentral() +} + +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/gradle/wrapper/gradle-wrapper.jar b/examples/realworld-app/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..d64cd4917 Binary files /dev/null and b/examples/realworld-app/gradle/wrapper/gradle-wrapper.jar differ 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..5fc84db67 --- /dev/null +++ b/examples/realworld-app/rw-database/build.gradle @@ -0,0 +1,45 @@ +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 { + // 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') + + // 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') +} + +tasks.named('test') { + useJUnitPlatform() +} 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 new file mode 100644 index 000000000..083366e7e --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/db/ArticleService.java @@ -0,0 +1,461 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package realworld.db; + +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.elasticsearch._types.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; +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; +import co.elastic.clients.elasticsearch.core.search.Hit; +import co.elastic.clients.util.NamedValue; +import com.github.slugify.Slugify; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +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; +import java.time.Instant; +import java.util.*; +import java.util.stream.Collectors; + +import static realworld.constant.Constants.ARTICLES; +import static realworld.constant.Constants.COMMENTS; +import static realworld.utils.Utility.*; + +@Service +public class ArticleService { + + private final ElasticsearchClient esClient; + + @Autowired + public ArticleService(ElasticsearchClient esClient) { + this.esClient = 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 the new article. + */ + public Article newArticle(ArticleCreationDTO articleDTO, Author author) throws IOException { + + // Checking if slug would be unique + String slug = generateAndCheckSlug(articleDTO.title()); + + Instant now = Instant.now(); + Article article = new Article(articleDTO, slug, now, now, author); + + IndexRequest

articleReq = IndexRequest.of((id -> id + .index(ARTICLES) + .refresh(Refresh.WaitFor) + .document(article))); + + esClient.index(articleReq); + + return article; + } + + /** + * Simple term query (see {@link UserService#findUserByUsername(String)}) to find an article + * given its unique slug. + * + * @return a pair containing the article and its id, + */ + public ArticleIdPair findArticleBySlug(String slug) throws IOException { + + SearchResponse
getArticle = esClient.search(ss -> ss + .index(ARTICLES) + .query(q -> q + .term(t -> t + .field("slug.keyword") + .value(slug)) + ) + , Article.class); + + if (getArticle.hits().hits().isEmpty()) { + return null; + } + return new ArticleIdPair(extractSource(getArticle), extractId(getArticle)); + } + + /** + * See {@link ArticleService#updateArticle(String, Article)} (String, User)} + *

+ * Updates an article, checking if the author is the same and if the new title's slug would be unique. + * + * @return the updated user. + */ + public ArticleDTO updateArticle(ArticleUpdateDTO article, String slug, Author author) throws IOException { + + // Getting original article from slug + ArticleIdPair articlePair = Optional.ofNullable(findArticleBySlug(slug)) + .orElseThrow(() -> new ResourceNotFoundException("Article not found")); + String id = articlePair.id(); + Article oldArticle = articlePair.article(); + + // Checking if author is the same + if (!oldArticle.author().username().equals(author.username())) { + throw new UnauthorizedException("Cannot modify article from another author"); + } + + String newSlug = slug; + // If title is being changed, checking if new slug would be unique + if (!isNullOrBlank(article.title()) && !article.title().equals(oldArticle.title())) { + newSlug = generateAndCheckSlug(article.title()); + } + + Instant updatedAt = Instant.now(); + + // Null/blank check for every optional field + Article updatedArticle = new Article(newSlug, + isNullOrBlank(article.title()) ? oldArticle.title() : article.title(), + isNullOrBlank(article.description()) ? oldArticle.description() : article.description(), + isNullOrBlank(article.body()) ? oldArticle.body() : article.body(), + oldArticle.tagList(), oldArticle.createdAt(), + updatedAt, oldArticle.favorited(), oldArticle.favoritesCount(), + oldArticle.favoritedBy(), oldArticle.author()); + + updateArticle(id, updatedArticle); + return new ArticleDTO(updatedArticle); + } + + /** + * Updates an article, given the updated object and its unique id. + */ + private void updateArticle(String id, Article updatedArticle) throws IOException { + UpdateResponse

upArticle = esClient.update(up -> up + .index(ARTICLES) + .id(id) + .doc(updatedArticle) + , Article.class); + if (!upArticle.result().name().equals("Updated")) { + throw new RuntimeException("Article update failed"); + } + } + + /** + * Deletes an article, using the slug to identify it, and all of its comments. + *

+ * Delete queries are very similar to search queries, + * here a term query (see {@link UserService#findUserByUsername(String)}) is used to match the + * correct article. + *

+ * The refresh value (see {@link ArticleService#newArticle(ArticleCreationDTO, Author)}) is + * set as "wait_for" for both delete queries, since the frontend will perform a get operation + * immediately after. The syntax for setting it as "wait_for" is different from the index operation, + * but the result is the same. + */ + public void deleteArticle(String slug, Author author) throws IOException { + + // Getting article from slug + ArticleIdPair articlePair = Optional.ofNullable(findArticleBySlug(slug)) + .orElseThrow(() -> new ResourceNotFoundException("Article not found")); + Article article = articlePair.article(); + + // Checking if author is the same + if (!article.author().username().equals(author.username())) { + throw new UnauthorizedException("Cannot delete article from another author"); + } + + DeleteByQueryResponse deleteArticle = esClient.deleteByQuery(d -> d + .index(ARTICLES) + .waitForCompletion(true) + .refresh(true) + .query(q -> q + .term(t -> t + .field("slug.keyword") + .value(slug)) + )); + if (deleteArticle.deleted() < 1) { + throw new RuntimeException("Failed to delete article"); + } + + // Delete every comment to the article, using a term query + // that will match all comments with the same articleSlug + DeleteByQueryResponse deleteCommentsByArticle = esClient.deleteByQuery(d -> d + .index(COMMENTS) + .waitForCompletion(true) + .refresh(true) + .query(q -> q + .term(t -> t + .field("articleSlug.keyword") + .value(slug)) + )); + } + + /** + * Adds the requesting user to the article's favoritedBy list. + * + * @return the target article. + */ + public Article markArticleAsFavorite(String slug, String username) throws IOException { + ArticleIdPair articlePair = Optional.ofNullable(findArticleBySlug(slug)) + .orElseThrow(() -> new ResourceNotFoundException("Article not found")); + String id = articlePair.id(); + Article article = articlePair.article(); + + // Checking if article was already marked as favorite + if (article.favoritedBy().contains(username)) { + return article; + } + + article.favoritedBy().add(username); + Article updatedArticle = new Article(article.slug(), article.title(), + article.description(), + article.body(), article.tagList(), article.createdAt(), article.updatedAt(), + true, article.favoritesCount() + 1, article.favoritedBy(), article.author()); + + updateArticle(id, updatedArticle); + return updatedArticle; + } + + /** + * Removes the requesting user from the article's favoritedBy list. + * + * @return the target article. + */ + public Article removeArticleFromFavorite(String slug, String username) throws IOException { + ArticleIdPair articlePair = Optional.ofNullable(findArticleBySlug(slug)) + .orElseThrow(() -> new ResourceNotFoundException("Article not found")); + String id = articlePair.id(); + Article article = articlePair.article(); + + // Checking if article was not marked as favorite before + if (!article.favoritedBy().contains(username)) { + return article; + } + + article.favoritedBy().remove(username); + int favoriteCount = article.favoritesCount() - 1; + boolean favorited = article.favorited(); + if (favoriteCount == 0) { + favorited = false; + } + + Article updatedArticle = new Article(article.slug(), article.title(), + article.description(), + article.body(), article.tagList(), article.createdAt(), article.updatedAt(), favorited, + favoriteCount, article.favoritedBy(), article.author()); + + updateArticle(id, updatedArticle); + return updatedArticle; + } + + /** + * Builds a search query using the filters the user is passing to retrieve all the matching articles. + *

+ * Since all the parameters are optional, the query must be build conditionally, adding one parameter + * at a time to the "conditions" array. + * Using a + * match + * query instead of a + * term + * query to allow the use of a single word for searching phrases, + * for example, filtering for articles with the "cat" tag will also return articles with the "cat food" + * tag. + *

+ * The articles are then sorted by the time they were last updated. + * + * @return a list containing all articles, filtered. + */ + public ArticlesDTO findArticles(String tag, String author, String favorited, Integer limit, + Integer offset, + Optional user) throws IOException { + List conditions = new ArrayList<>(); + + if (!isNullOrBlank(tag)) { + conditions.add(new Builder() + .field("tagList") + .query(tag).build()._toQuery()); + } + if (!isNullOrBlank(author)) { + conditions.add(new Builder() + .field("author.username") + .query(author).build()._toQuery()); + } + // Alternative equivalent syntax to build the match query without using the Builder explicitly + if (!isNullOrBlank(favorited)) { + conditions.add(MatchQuery.of(mq -> mq + .field("favoritedBy") + .query(favorited)) + ._toQuery()); + } + + Query query = new Query.Builder().bool(b -> b.should(conditions)).build(); + + SearchResponse

getArticle = esClient.search(ss -> ss + .index(ARTICLES) + .size(limit) // how many results to return + .from(offset) // starting point + .query(query) + .sort(srt -> srt + .field(fld -> fld + .field("updatedAt") + .order(SortOrder.Desc))) // last updated first + , Article.class); + + // Making the output adhere to the API specification + return new ArticlesDTO(getArticle.hits().hits() + .stream() + .map(Hit::source) + // If tag specified, put that tag first in the array + .peek(a -> { + if (!isNullOrBlank(tag) && a.tagList().contains(tag)) { + Collections.swap(a.tagList(), a.tagList().indexOf(tag), 0); + } + }) + .map(ArticleForListDTO::new) + // If auth was provided, filling the "following" field of "Author" accordingly + .map(a -> { + if (user.isPresent()) { + boolean following = user.get().following().contains(a.author().username()); + return new ArticleForListDTO(a, new Author(a.author().username(), + a.author().email(), a.author().bio(), following)); + } + return a; + }) + .collect(Collectors.toList()), getArticle.hits().hits().size()); + } + + /** + * Searches the article index for articles created by multiple users, + * using a + * terms query, + * which works like a + * term query + * that can match multiple values for the same field. + *

+ * The fields of the nested object "author" are easily accessible using the dot notation, for example + * "author.username". + *

+ * The articles are sorted by the time they were last updated. + * + * @return a list of articles from followed users. + */ + public ArticlesDTO generateArticleFeed(User user) throws IOException { + // Preparing authors filter from user data + List authorsFilter = user.following().stream() + .map(FieldValue::of).toList(); + + SearchResponse

articlesByAuthors = esClient.search(ss -> ss + .index(ARTICLES) + .query(q -> q + .bool(b -> b + .filter(f -> f + .terms(t -> t + .field("author.username.keyword") + .terms(TermsQueryField.of(tqf -> tqf.value(authorsFilter))) + )))) + .sort(srt -> srt + .field(fld -> fld + .field("updatedAt") + .order(SortOrder.Desc))) + , Article.class); + + return new ArticlesDTO(articlesByAuthors.hits().hits() + .stream() + .map(Hit::source) + .map(ArticleForListDTO::new) + // Filling the "following" field of "Author" accordingly + .map(a -> { + boolean following = user.following().contains(a.author().username()); + return new ArticleForListDTO(a, new Author(a.author().username(), + a.author().email(), a.author().bio(), following)); + }) + .collect(Collectors.toList()), articlesByAuthors.hits().hits().size()); + } + + + /** + * Searches the article index to retrieve a list of each distinct tag, using an + * aggregation , + * more specifically a + * terms aggregation + *

+ * The resulting list of tags is sorted by document count (how many times they appear in different + * documents). + * + * @return a list of all tags. + */ + public TagsDTO findAllTags() throws IOException { + + // If alphabetical order is preferred, use "_key" instead + NamedValue sort = new NamedValue<>("_count", SortOrder.Desc); + + SearchResponse aggregateTags = esClient.search(s -> s + .index(ARTICLES) + .size(0) // this is needed avoid returning the search result, which is not necessary here + .aggregations("tags", agg -> agg + .terms(ter -> ter + .field("tagList.keyword") + .order(sort)) + ), + Aggregation.class + ); + + return new TagsDTO(aggregateTags.aggregations().get("tags") + .sterms().buckets() + .array().stream() + .map(st -> st.key().stringValue()) + .collect(Collectors.toList()) + ); + } + + /** + * Uses the Slugify library to generate the slug of the input string, then checks its uniqueness. + * + * @return the "slugified" string. + */ + private String generateAndCheckSlug(String title) throws IOException { + String slug = Slugify.builder().build().slugify(title); + if (Objects.nonNull(findArticleBySlug(slug))) { + throw new ResourceAlreadyExistsException("Article slug already exists, please change the title"); + } + return slug; + } + +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/db/CommentService.java b/examples/realworld-app/rw-database/src/main/java/realworld/db/CommentService.java new file mode 100644 index 000000000..24b87b8da --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/db/CommentService.java @@ -0,0 +1,145 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package realworld.db; + +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.elasticsearch._types.Refresh; +import co.elastic.clients.elasticsearch.core.DeleteByQueryResponse; +import co.elastic.clients.elasticsearch.core.IndexRequest; +import co.elastic.clients.elasticsearch.core.SearchResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import realworld.document.article.ArticleCreationDTO; +import realworld.document.comment.Comment; +import realworld.document.comment.CommentCreationDTO; +import realworld.document.comment.CommentForListDTO; +import realworld.document.comment.CommentsDTO; +import realworld.document.user.Author; +import realworld.document.user.RegisterDTO; +import realworld.document.user.User; + +import java.io.IOException; +import java.security.SecureRandom; +import java.time.Instant; +import java.util.Optional; +import java.util.stream.Collectors; + +import static realworld.constant.Constants.COMMENTS; + +@Service +public class CommentService { + + private final ElasticsearchClient esClient; + + @Autowired + public CommentService(ElasticsearchClient esClient) { + this.esClient = esClient; + } + + /** + * Creates a new comment and saves it into the comment index. + * The refresh value is specified for the same reason explained in + * {@link ArticleService#newArticle(ArticleCreationDTO, Author)} + * + * @return the newly created comment. + */ + public Comment newComment(CommentCreationDTO commentDTO, String slug, User user) throws IOException { + // assuming you cannot follow yourself + Author commentAuthor = new Author(user, false); + Instant now = Instant.now(); + + // pre-generating id since it's a field in the comment class + Long commentId = Long.valueOf(String.valueOf(new SecureRandom().nextLong()).substring(0, 15)); + Comment comment = new Comment(commentId, now, now, commentDTO.body(), commentAuthor, + slug); + + IndexRequest commentReq = IndexRequest.of((id -> id + .index(COMMENTS) + .refresh(Refresh.WaitFor) + .document(comment))); + + + esClient.index(commentReq); + + return comment; + } + + /** + * Deletes a specific comment making sure that the user performing the operation is the author of the + * comment itself. + *

+ * A boolean query similar to the one used in {@link UserService#newUser(RegisterDTO)} is used, + * matching both the comment id and the author's username, with a difference: here "must" is used + * instead of "should", meaning that the documents must match both conditions at the same time. + * + * @return The authenticated user. + */ + public void deleteComment(String commentId, String username) throws IOException { + + DeleteByQueryResponse deleteComment = esClient.deleteByQuery(ss -> ss + .index(COMMENTS) + .waitForCompletion(true) + .refresh(true) + .query(q -> q + .bool(b -> b + .must(m -> m + .term(t -> t + .field("id") + .value(commentId)) + ).must(m -> m + .term(t -> t + .field("author.username.keyword") + .value(username)))) + )); + if (deleteComment.deleted() < 1) { + throw new RuntimeException("Failed to delete comment"); + } + } + + /** + * Retrieves all comments with the same articleSlug value using a term query + * (see {@link UserService#findUserByUsername(String)}). + * + * @return a list of comment belonging to a single article. + */ + public CommentsDTO findAllCommentsByArticle(String slug, Optional user) throws IOException { + SearchResponse commentsByArticle = esClient.search(s -> s + .index(COMMENTS) + .query(q -> q + .term(t -> t + .field("articleSlug.keyword") + .value(slug)) + ) + , Comment.class); + + return new CommentsDTO(commentsByArticle.hits().hits().stream() + .map(x -> new CommentForListDTO(x.source())) + .map(c -> { + if (user.isPresent()) { + boolean following = user.get().following().contains(c.author().username()); + return new CommentForListDTO(c.id(), c.createdAt(), c.updatedAt(), c.body(), + new Author(c.author().username(), c.author().email(), c.author().bio(), + following)); + } + return c; + }) + .collect(Collectors.toList())); + } +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/db/ElasticClient.java b/examples/realworld-app/rw-database/src/main/java/realworld/db/ElasticClient.java new file mode 100644 index 000000000..e9270c701 --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/db/ElasticClient.java @@ -0,0 +1,130 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package realworld.db; + +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.json.jackson.JacksonJsonpMapper; +import co.elastic.clients.transport.ElasticsearchTransport; +import co.elastic.clients.transport.endpoints.BooleanResponse; +import co.elastic.clients.transport.rest_client.RestClientTransport; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.apache.http.Header; +import org.apache.http.HttpHost; +import org.apache.http.message.BasicHeader; +import org.elasticsearch.client.RestClient; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.io.IOException; + +import static realworld.constant.Constants.*; + +@Configuration +public class ElasticClient { + + @Value("${elasticsearch.server.url}") + private String serverUrl; + + @Value("${elasticsearch.api.key}") + private String apiKey; + + /** + * Creates the ElasticsearchClient and the indexes needed + * + * @return a configured ElasticsearchClient + */ + @Bean + public ElasticsearchClient elasticRestClient() throws IOException { + + // Create the low-level client + RestClient restClient = RestClient + .builder(HttpHost.create(serverUrl)) + .setDefaultHeaders(new Header[]{ + new BasicHeader("Authorization", "ApiKey " + apiKey) + }) + .build(); + + // The transport layer of the Elasticsearch client requires a json object mapper to + // define how to serialize/deserialize java objects. The mapper can be customized by adding + // modules, for example since the Article and Comment object both have Instant fields, the + // JavaTimeModule is added to provide support for java 8 Time classes, which the mapper itself does + // not support. + ObjectMapper mapper = JsonMapper.builder() + .addModule(new JavaTimeModule()) + .build(); + + // Create the transport with the Jackson mapper + ElasticsearchTransport transport = new RestClientTransport( + restClient, new JacksonJsonpMapper(mapper)); + + // Create the API client + ElasticsearchClient esClient = new ElasticsearchClient(transport); + + // Creating the indexes + createSimpleIndex(esClient, USERS); + createIndexWithDateMapping(esClient, ARTICLES); + createIndexWithDateMapping(esClient, COMMENTS); + + return esClient; + } + + /** + * Plain simple + * index + * creation with an + * + * exists check + */ + private void createSimpleIndex(ElasticsearchClient esClient, String index) throws IOException { + BooleanResponse indexRes = esClient.indices().exists(ex -> ex.index(index)); + if (!indexRes.value()) { + esClient.indices().create(c -> c + .index(index)); + } + } + + /** + * If no explicit mapping is defined, elasticsearch will dynamically map types when converting data to + * the json + * format. Adding explicit mapping to the date fields assures that no precision will be lost. More + * information about + * dynamic + * field mapping, more on mapping date + * format + */ + private void createIndexWithDateMapping(ElasticsearchClient esClient, String index) throws IOException { + BooleanResponse indexRes = esClient.indices().exists(ex -> ex.index(index)); + if (!indexRes.value()) { + esClient.indices().create(c -> c + .index(index) + .mappings(m -> m + .properties("createdAt", p -> p + .date(d -> d.format("strict_date_optional_time"))) + .properties("updatedAt", p -> p + .date(d -> d.format("strict_date_optional_time"))))); + + } + } +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/db/UserService.java b/examples/realworld-app/rw-database/src/main/java/realworld/db/UserService.java new file mode 100644 index 000000000..b35ae4225 --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/db/UserService.java @@ -0,0 +1,407 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package realworld.db; + +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.elasticsearch._types.Refresh; +import co.elastic.clients.elasticsearch.core.IndexRequest; +import co.elastic.clients.elasticsearch.core.SearchResponse; +import co.elastic.clients.elasticsearch.core.UpdateResponse; +import co.elastic.clients.elasticsearch.core.search.Hit; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.impl.TextCodec; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import realworld.document.exception.ResourceAlreadyExistsException; +import realworld.document.exception.ResourceNotFoundException; +import realworld.document.exception.UnauthorizedException; +import realworld.document.user.*; +import realworld.utils.UserIdPair; + +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; +import java.io.IOException; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.KeySpec; +import java.time.Instant; +import java.util.*; + +import static realworld.constant.Constants.USERS; +import static realworld.utils.Utility.*; + +@Service +public class UserService { + + private final ElasticsearchClient esClient; + + @Value("${jwt.signing.key}") + private String jwtSigningKey; + + @Autowired + public UserService(ElasticsearchClient esClient) { + this.esClient = esClient; + } + + /** + * Inserts a new User into the "users" index, checking beforehand whether the username and email + * are unique. + *

+ * See {@link UserService#findUserByUsername(String)} for details on how the term query works. + *

+ * Combining multiple term queries into a single + * boolean query with "should" occur + * to match documents fulfilling either conditions to check the uniqueness of the new email and username. + *

+ * When the new user document is created, it is left up to elasticsearch to create a unique + * id field , since there's no user field that is guaranteed not to be updated/modified. + * + * @return The newly registered user. + */ + public User newUser(RegisterDTO user) throws IOException { + + // Checking uniqueness of both email and username + SearchResponse checkUser = esClient.search(ss -> ss + .index(USERS) + .query(q -> q + .bool(b -> b + .should(s -> s + .term(t -> t + .field("email.keyword") + .value(user.email())) + ).should(s -> s + .term(t -> t + .field("username.keyword") + .value(user.username()))))) + , User.class); + + checkUser.hits().hits().stream() + .map(Hit::source) + .filter(x -> x.username().equals(user.username())) + .findFirst() + .ifPresent(x -> { + throw new ResourceAlreadyExistsException("Username already exists"); + }); + + checkUser.hits().hits().stream() + .map(Hit::source) + .filter(x -> x.email().equals(user.email())) + .findFirst() + .ifPresent(x -> { + throw new ResourceAlreadyExistsException("Email already used"); + }); + + // Building user's JWT, with no expiration since it's not requested + String jws = Jwts.builder() + .setIssuer("rw-backend") + .setSubject(user.email()) + .claim("name", user.username()) + .claim("scope", "user") + .setIssuedAt(Date.from(Instant.now())) + .signWith( + SignatureAlgorithm.HS256, + TextCodec.BASE64.decode(jwtSigningKey) + ) + .compact(); + + // Hashing the password, storing the salt with the user + SecureRandom secureRandom = new SecureRandom(); + byte[] salt = new byte[16]; + secureRandom.nextBytes(salt); + String hashedPw = hashUserPw(user.password(), salt); + + User newUser = new User(user.username(), user.email(), + hashedPw, jws, "", "", salt, new ArrayList<>()); + + // Creating the index request + IndexRequest userReq = IndexRequest.of((id -> id + .index(USERS) + .refresh(Refresh.WaitFor) + .document(newUser))); + + // Indexing the request (inserting it into to database) + esClient.index(userReq); + + return newUser; + } + + /** + * Using a simple term query (see {@link UserService#findUserByUsername(String)} for details) + * to find the user using the same unique email as the one provided. The password is then hashed and + * checked after the search. + * + * @return The authenticated user. + */ + public User authenticateUser(LoginDTO login) throws IOException { + + SearchResponse getUser = esClient.search(ss -> ss + .index(USERS) + .query(q -> q + .term(t -> t + .field("email.keyword") + .value(login.email()))) + , User.class); + + + if (getUser.hits().hits().isEmpty()) { + throw new ResourceNotFoundException("Email not found"); + } + + // Check if the hashed password matches the one provided + User user = extractSource(getUser); + String hashedPw = hashUserPw(login.password(), user.salt()); + + if (!hashedPw.equals(user.password())) { + throw new UnauthorizedException("Wrong password"); + } + return user; + } + + /** + * Deserializing and checking the token, then performing a term query + * (see {@link UserService#findUserByUsername(String)} for details) using the token string to retrieve + * the corresponding user. + * + * @return a pair containing the result of the term query, a single user, with its id. + */ + public UserIdPair findUserByToken(String auth) throws IOException { + String token; + try { + token = auth.split(" ")[1]; + Jwts.parser() + .setSigningKey(TextCodec.BASE64.decode(jwtSigningKey)) + .parse(token); + } catch (Exception e) { + throw new UnauthorizedException("Token missing or not recognised"); + } + + SearchResponse getUser = esClient.search(ss -> ss + .index(USERS) + .query(q -> q + .term(t -> t + .field("token.keyword") + .value(token)) + ) + , User.class); + + if (getUser.hits().hits().isEmpty()) { + throw new ResourceNotFoundException("Token not assigned to any user"); + } + return new UserIdPair(extractSource(getUser), extractId(getUser)); + } + + /** + * See {@link UserService#updateUser(String, User)} + *

+ * Updates a user, checking before if the new username or email would be unique. + * + * @return the updated user. + */ + public User updateUser(UserDTO userDTO, String auth) throws IOException { + + UserIdPair userPair = findUserByToken(auth); + User user = userPair.user(); + + // If the username or email are updated, checking uniqueness + if (!isNullOrBlank(userDTO.username()) && !userDTO.username().equals(user.username())) { + UserIdPair newUsernameSearch = findUserByUsername(userDTO.username()); + if (Objects.nonNull(newUsernameSearch)) { + throw new ResourceAlreadyExistsException("Username already exists"); + } + } + + if (!isNullOrBlank(userDTO.email()) && !userDTO.email().equals(user.email())) { + UserIdPair newUsernameSearch = findUserByEmail(userDTO.username()); + if (Objects.nonNull(newUsernameSearch)) { + throw new ResourceAlreadyExistsException("Email already in use"); + } + } + + // Null/blank check for every optional field + User updatedUser = new User(isNullOrBlank(userDTO.username()) ? user.username() : + userDTO.username(), + isNullOrBlank(userDTO.email()) ? user.email() : userDTO.email(), + user.password(), user.token(), + isNullOrBlank(userDTO.bio()) ? user.bio() : userDTO.bio(), + isNullOrBlank(userDTO.image()) ? user.image() : userDTO.image(), + user.salt(), user.following()); + + updateUser(userPair.id(), updatedUser); + return updatedUser; + } + + /** + * Updates a user, given the updated object and its unique id. + */ + private void updateUser(String id, User user) throws IOException { + UpdateResponse upUser = esClient.update(up -> up + .index(USERS) + .id(id) + .doc(user) + , User.class); + if (!upUser.result().name().equals("Updated")) { + throw new RuntimeException("User update failed"); + } + } + + /** + * Retrieves data for the requested user and the asking user to provide profile information. + * + * @return the requested user's profile. + */ + public Profile findUserProfile(String username, String auth) throws IOException { + + UserIdPair targetUserPair = Optional.ofNullable(findUserByUsername(username)) + .orElseThrow(() -> new ResourceNotFoundException("User not found")); + User targetUser = targetUserPair.user(); + + // Checking if the user is followed by who's asking + UserIdPair askingUserPair = findUserByToken(auth); + boolean following = askingUserPair.user().following().contains(targetUser.username()); + + return new Profile(targetUser, following); + } + + /** + * Adds the targed user to the asking user's list of followed profiles. + * + * @return the target user's profile. + */ + public Profile followUser(String username, String auth) throws IOException { + + UserIdPair targetUserPair = Optional.ofNullable(findUserByUsername(username)) + .orElseThrow(() -> new ResourceNotFoundException("User not found")); + User targetUser = targetUserPair.user(); + + UserIdPair askingUserPair = findUserByToken(auth); + User askingUser = askingUserPair.user(); + + if (askingUser.username().equals(targetUser.username())) { + throw new RuntimeException("Cannot follow yourself!"); + } + + // Add followed user to list if not already present + if (!askingUser.following().contains(targetUser.username())) { + askingUser.following().add(targetUser.username()); + + updateUser(askingUserPair.id(), askingUser); + } + + return new Profile(targetUser, true); + } + + /** + * Removes the targed user from the asking user's list of followed profiles. + * + * @return the target user's profile. + */ + public Profile unfollowUser(String username, String auth) throws IOException { + UserIdPair targetUserPair = Optional.ofNullable(findUserByUsername(username)) + .orElseThrow(() -> new ResourceNotFoundException("User not found")); + User targetUser = targetUserPair.user(); + + UserIdPair askingUserPair = findUserByToken(auth); + User askingUser = askingUserPair.user(); + + // Remove followed user to list if not already present + if (askingUser.following().contains(targetUser.username())) { + askingUser.following().remove(targetUser.username()); + + updateUser(askingUserPair.id(), askingUser); + } + + return new Profile(targetUser, false); + } + + /** + * Searches the "users" index for a document containing the exact same username. + *

+ * A + * term query + * means that it will find only results that match character by character. + *

+ * Using the + * keyword + * property of the field allows to use the original value of the string while querying, instead of the + * processed/tokenized value. + * + * @return a pair containing the result of the term query, a single user, with its id. + */ + public UserIdPair findUserByUsername(String username) throws IOException { + // Simple term query to match exactly the username string + SearchResponse getUser = esClient.search(ss -> ss + .index(USERS) + .query(q -> q + .term(t -> t + .field("username.keyword") + .value(username))) + , User.class); + if (getUser.hits().hits().isEmpty()) { + return null; + } + return new UserIdPair(extractSource(getUser), extractId(getUser)); + } + + /** + * Searches the "users" index for a document containing the exact same email. + * See {@link UserService#findUserByUsername(String)} for details. + * + * @return the result of the term query, a single user, with its id. + */ + private UserIdPair findUserByEmail(String email) throws IOException { + // Simple term query to match exactly the email string + SearchResponse getUser = esClient.search(ss -> ss + .index(USERS) + .query(q -> q + .term(t -> t + .field("email.keyword") + .value(email))) + , User.class); + if (getUser.hits().hits().isEmpty()) { + return null; + } + return new UserIdPair(extractSource(getUser), extractId(getUser)); + } + + /** + * Hashes a string using the PBKDF2 method. + * + * @return the hashed string. + */ + private String hashUserPw(String password, byte[] salt) { + + KeySpec keySpec = new PBEKeySpec(password.toCharArray(), salt, 65536, 128); + String hashedPw = null; + try { + SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1"); + byte[] hash = secretKeyFactory.generateSecret(keySpec).getEncoded(); + Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding(); + hashedPw = encoder.encodeToString(hash); + } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { + throw new RuntimeException(e); + } + return hashedPw; + } +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/document/article/Article.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/article/Article.java new file mode 100644 index 000000000..b06213bb4 --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/article/Article.java @@ -0,0 +1,49 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package realworld.document.article; + +import com.fasterxml.jackson.annotation.JsonFormat; +import realworld.document.user.Author; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +public record Article( + String slug, + String title, + String description, + String body, + List tagList, + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS", timezone = "UTC") + Instant createdAt, + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS", timezone = "UTC") + Instant updatedAt, + boolean favorited, + int favoritesCount, + List favoritedBy, + Author author) { + + public Article(ArticleCreationDTO article, String slug, Instant createdAt, Instant updatedAt, + Author author) { + this(slug, article.title(), article.description(), article.body(), article.tagList(), createdAt, + updatedAt, false, 0, new ArrayList<>(), author); + } +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/document/article/ArticleCreationDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/article/ArticleCreationDTO.java new file mode 100644 index 000000000..94d23084a --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/article/ArticleCreationDTO.java @@ -0,0 +1,34 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package realworld.document.article; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonTypeInfo.As; +import com.fasterxml.jackson.annotation.JsonTypeInfo.Id; +import com.fasterxml.jackson.annotation.JsonTypeName; +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +@JsonTypeName("article") +@JsonTypeInfo(include = As.WRAPPER_OBJECT, use = Id.NAME) +public record ArticleCreationDTO(@NotNull String title, @NotNull String description, @NotNull String body, + List tagList) { +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/document/article/ArticleDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/article/ArticleDTO.java new file mode 100644 index 000000000..94796110e --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/article/ArticleDTO.java @@ -0,0 +1,54 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package realworld.document.article; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonTypeInfo.As; +import com.fasterxml.jackson.annotation.JsonTypeInfo.Id; +import com.fasterxml.jackson.annotation.JsonTypeName; +import realworld.document.user.Author; + +import java.time.Instant; +import java.util.List; + +@JsonTypeName("article") +@JsonTypeInfo(include = As.WRAPPER_OBJECT, use = Id.NAME) +public record ArticleDTO( + String slug, + String title, + String description, + String body, + List tagList, + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS", timezone = "UTC") + Instant createdAt, + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS", timezone = "UTC") + Instant updatedAt, + boolean favorited, + int favoritesCount, + Author author) { + + + public ArticleDTO(Article article) { + this(article.slug(), article.title(), article.description(), article.body(), article.tagList(), + article.createdAt(), article.updatedAt(), + article.favorited(), article.favoritesCount(), article.author()); + } +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/document/article/ArticleForListDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/article/ArticleForListDTO.java new file mode 100644 index 000000000..a602ac426 --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/article/ArticleForListDTO.java @@ -0,0 +1,54 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package realworld.document.article; + +import com.fasterxml.jackson.annotation.JsonFormat; +import realworld.document.user.Author; + +import java.time.Instant; +import java.util.List; + +public record ArticleForListDTO( + String slug, + String title, + String description, + String body, + List tagList, + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS", timezone = "UTC") + Instant createdAt, + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS", timezone = "UTC") + Instant updatedAt, + boolean favorited, + int favoritesCount, + Author author) { + + + public ArticleForListDTO(Article article) { + this(article.slug(), article.title(), article.description(), article.body(), article.tagList(), + article.createdAt(), article.updatedAt(), + article.favorited(), article.favoritesCount(), article.author()); + } + + public ArticleForListDTO(ArticleForListDTO article, Author author) { + this(article.slug(), article.title(), article.description(), article.body(), article.tagList(), + article.createdAt(), article.updatedAt(), article.favorited(), article.favoritesCount(), + author); + } +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/document/article/ArticleUpdateDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/article/ArticleUpdateDTO.java new file mode 100644 index 000000000..3cbcb0b3e --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/article/ArticleUpdateDTO.java @@ -0,0 +1,30 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package realworld.document.article; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonTypeInfo.As; +import com.fasterxml.jackson.annotation.JsonTypeInfo.Id; +import com.fasterxml.jackson.annotation.JsonTypeName; + +@JsonTypeName("article") +@JsonTypeInfo(include = As.WRAPPER_OBJECT, use = Id.NAME) +public record ArticleUpdateDTO(String title, String description, String body) { +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/document/article/ArticlesDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/article/ArticlesDTO.java new file mode 100644 index 000000000..46411646c --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/article/ArticlesDTO.java @@ -0,0 +1,25 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package realworld.document.article; + +import java.util.List; + +public record ArticlesDTO(List articles, int articlesCount) { +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/document/article/TagsDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/article/TagsDTO.java new file mode 100644 index 000000000..72c3edc2b --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/article/TagsDTO.java @@ -0,0 +1,25 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package realworld.document.article; + +import java.util.List; + +public record TagsDTO(List tags) { +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/document/comment/Comment.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/comment/Comment.java new file mode 100644 index 000000000..682c1ecd1 --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/comment/Comment.java @@ -0,0 +1,36 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package realworld.document.comment; + +import com.fasterxml.jackson.annotation.JsonFormat; +import realworld.document.user.Author; + +import java.time.Instant; + +public record Comment( + Long id, + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS", timezone = "UTC") + Instant createdAt, + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS", timezone = "UTC") + Instant updatedAt, + String body, + Author author, + String articleSlug) { +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/document/comment/CommentCreationDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/comment/CommentCreationDTO.java new file mode 100644 index 000000000..0cca6a407 --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/comment/CommentCreationDTO.java @@ -0,0 +1,31 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package realworld.document.comment; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonTypeInfo.As; +import com.fasterxml.jackson.annotation.JsonTypeInfo.Id; +import com.fasterxml.jackson.annotation.JsonTypeName; +import jakarta.validation.constraints.NotNull; + +@JsonTypeName("comment") +@JsonTypeInfo(include = As.WRAPPER_OBJECT, use = Id.NAME) +public record CommentCreationDTO(@NotNull String body) { +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/document/comment/CommentDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/comment/CommentDTO.java new file mode 100644 index 000000000..b18db813e --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/comment/CommentDTO.java @@ -0,0 +1,47 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package realworld.document.comment; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonTypeInfo.As; +import com.fasterxml.jackson.annotation.JsonTypeInfo.Id; +import com.fasterxml.jackson.annotation.JsonTypeName; +import realworld.document.user.Author; + +import java.time.Instant; + +@JsonTypeName("comment") +@JsonTypeInfo(include = As.WRAPPER_OBJECT, use = Id.NAME) +public record CommentDTO( + Long id, + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS", timezone = "UTC") + Instant createdAt, + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS", timezone = "UTC") + Instant updatedAt, + String body, + Author author) { + + public CommentDTO(Comment comment) { + this(comment.id(), comment.createdAt(), + comment.updatedAt(), comment.body(), + comment.author()); + } +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/document/comment/CommentForListDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/comment/CommentForListDTO.java new file mode 100644 index 000000000..f5111a2fc --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/comment/CommentForListDTO.java @@ -0,0 +1,41 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package realworld.document.comment; + +import com.fasterxml.jackson.annotation.JsonFormat; +import realworld.document.user.Author; + +import java.time.Instant; + +public record CommentForListDTO( + Long id, + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS", timezone = "UTC") + Instant createdAt, + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS", timezone = "UTC") + Instant updatedAt, + String body, + Author author) { + + public CommentForListDTO(Comment comment) { + this(comment.id(), comment.createdAt(), + comment.updatedAt(), comment.body(), + comment.author()); + } +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/document/comment/CommentsDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/comment/CommentsDTO.java new file mode 100644 index 000000000..80bc5d802 --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/comment/CommentsDTO.java @@ -0,0 +1,25 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package realworld.document.comment; + +import java.util.List; + +public record CommentsDTO(List comments) { +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/document/exception/ResourceAlreadyExistsException.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/exception/ResourceAlreadyExistsException.java new file mode 100644 index 000000000..ebf0e497f --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/exception/ResourceAlreadyExistsException.java @@ -0,0 +1,27 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package realworld.document.exception; + +public class ResourceAlreadyExistsException extends RuntimeException { + + public ResourceAlreadyExistsException(String message) { + super(message); + } +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/document/exception/ResourceNotFoundException.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/exception/ResourceNotFoundException.java new file mode 100644 index 000000000..b861cc55e --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/exception/ResourceNotFoundException.java @@ -0,0 +1,27 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package realworld.document.exception; + +public class ResourceNotFoundException extends RuntimeException { + + public ResourceNotFoundException(String message) { + super(message); + } +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/document/exception/UnauthorizedException.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/exception/UnauthorizedException.java new file mode 100644 index 000000000..d94f896d4 --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/exception/UnauthorizedException.java @@ -0,0 +1,27 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package realworld.document.exception; + +public class UnauthorizedException extends RuntimeException { + + public UnauthorizedException(String message) { + super(message); + } +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/document/user/Author.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/user/Author.java new file mode 100644 index 000000000..b2bf8afa5 --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/user/Author.java @@ -0,0 +1,27 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package realworld.document.user; + +public record Author(String username, String email, String bio, boolean following) { + + public Author(User user, boolean following) { + this(user.username(), user.email(), user.bio(), following); + } +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/document/user/LoginDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/user/LoginDTO.java new file mode 100644 index 000000000..300748b77 --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/user/LoginDTO.java @@ -0,0 +1,29 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package realworld.document.user; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonTypeName; +import jakarta.validation.constraints.NotNull; + +@JsonTypeName("user") +@JsonTypeInfo(include = JsonTypeInfo.As.WRAPPER_OBJECT, use = JsonTypeInfo.Id.NAME) +public record LoginDTO(@NotNull String email, @NotNull String password) { +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/document/user/Profile.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/user/Profile.java new file mode 100644 index 000000000..a62704f4a --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/user/Profile.java @@ -0,0 +1,34 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package realworld.document.user; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonTypeInfo.As; +import com.fasterxml.jackson.annotation.JsonTypeInfo.Id; +import com.fasterxml.jackson.annotation.JsonTypeName; + +@JsonTypeName("profile") +@JsonTypeInfo(include = As.WRAPPER_OBJECT, use = Id.NAME) +public record Profile(String username, String image, String bio, boolean following) { + + public Profile(User user, boolean following) { + this(user.username(), user.image(), user.bio(), following); + } +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/document/user/RegisterDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/user/RegisterDTO.java new file mode 100644 index 000000000..96cae013b --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/user/RegisterDTO.java @@ -0,0 +1,29 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package realworld.document.user; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonTypeName; +import jakarta.validation.constraints.NotNull; + +@JsonTypeName("user") +@JsonTypeInfo(include = JsonTypeInfo.As.WRAPPER_OBJECT, use = JsonTypeInfo.Id.NAME) +public record RegisterDTO(@NotNull String username, @NotNull String email, @NotNull String password) { +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/document/user/User.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/user/User.java new file mode 100644 index 000000000..bbcb967f7 --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/user/User.java @@ -0,0 +1,33 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package realworld.document.user; + +import java.util.List; + +public record User( + String username, + String email, + String password, + String token, + String bio, + String image, + byte[] salt, + List following) { +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/document/user/UserDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/user/UserDTO.java new file mode 100644 index 000000000..92ff08939 --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/user/UserDTO.java @@ -0,0 +1,39 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package realworld.document.user; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonTypeInfo.As; +import com.fasterxml.jackson.annotation.JsonTypeInfo.Id; +import com.fasterxml.jackson.annotation.JsonTypeName; + +@JsonTypeName("user") +@JsonTypeInfo(include = As.WRAPPER_OBJECT, use = Id.NAME) +public record UserDTO( + String username, + String email, + String token, + String bio, + String image) { + + public UserDTO(User user) { + this(user.username(), user.email(), user.token(), user.bio(), user.image()); + } +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/utils/ArticleIdPair.java b/examples/realworld-app/rw-database/src/main/java/realworld/utils/ArticleIdPair.java new file mode 100644 index 000000000..81f4f113d --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/utils/ArticleIdPair.java @@ -0,0 +1,25 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package realworld.utils; + +import realworld.document.article.Article; + +public record ArticleIdPair(Article article, String id) { +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/utils/UserIdPair.java b/examples/realworld-app/rw-database/src/main/java/realworld/utils/UserIdPair.java new file mode 100644 index 000000000..d8ffb1ecc --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/utils/UserIdPair.java @@ -0,0 +1,25 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package realworld.utils; + +import realworld.document.user.User; + +public record UserIdPair(User user, String id) { +} diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/utils/Utility.java b/examples/realworld-app/rw-database/src/main/java/realworld/utils/Utility.java new file mode 100644 index 000000000..700f85725 --- /dev/null +++ b/examples/realworld-app/rw-database/src/main/java/realworld/utils/Utility.java @@ -0,0 +1,49 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package realworld.utils; + +import co.elastic.clients.elasticsearch.core.SearchResponse; + +import java.util.Objects; + +public class Utility { + + public static boolean isNullOrBlank(String s) { + return Objects.isNull(s) || s.isBlank(); + } + + /** + * Utility method to be used for single result queries. + * + * @return The document id. + */ + public static String extractId(SearchResponse searchResponse) { + return searchResponse.hits().hits().getFirst().id(); + } + + /** + * Utility method to be used for single result queries. + * + * @return An object of the class that was specified in the query definition. + */ + public static TDocument extractSource(SearchResponse searchResponse) { + return searchResponse.hits().hits().getFirst().source(); + } +} diff --git a/examples/realworld-app/rw-database/src/test/java/realworld/db/ElasticClientTest.java b/examples/realworld-app/rw-database/src/test/java/realworld/db/ElasticClientTest.java new file mode 100644 index 000000000..fb4940ac4 --- /dev/null +++ b/examples/realworld-app/rw-database/src/test/java/realworld/db/ElasticClientTest.java @@ -0,0 +1,113 @@ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package realworld.db; + +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.json.jackson.JacksonJsonpMapper; +import co.elastic.clients.transport.ElasticsearchTransport; +import co.elastic.clients.transport.endpoints.BooleanResponse; +import co.elastic.clients.transport.rest_client.RestClientTransport; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.apache.http.HttpHost; +import org.apache.http.auth.AuthScope; +import org.apache.http.auth.UsernamePasswordCredentials; +import org.apache.http.impl.client.BasicCredentialsProvider; +import org.elasticsearch.client.RestClient; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.testcontainers.elasticsearch.ElasticsearchContainer; + +import javax.net.ssl.SSLContext; +import java.io.IOException; +import java.time.Duration; + +import static realworld.constant.Constants.*; + +@Configuration +public class ElasticClientTest { + + @Bean + public ElasticsearchClient elasticRestClient() throws IOException { + // Creating the testcontainer + String image = "docker.elastic.co/elasticsearch/elasticsearch:8.11.4"; + ElasticsearchContainer container = new ElasticsearchContainer(image) + .withEnv("ES_JAVA_OPTS", "-Xms256m -Xmx256m") + .withEnv("path.repo", "/tmp") // for snapshots + .withStartupTimeout(Duration.ofSeconds(30)) + .withPassword("changeme"); + container.start(); + + // Connection settings + int port = container.getMappedPort(9200); + HttpHost host = new HttpHost("localhost", port, "https"); + SSLContext sslContext = container.createSslContextFromCa(); + + BasicCredentialsProvider credsProv = new BasicCredentialsProvider(); + credsProv.setCredentials( + AuthScope.ANY, new UsernamePasswordCredentials("elastic", "changeme") + ); + + // Building the rest client + RestClient restClient = RestClient.builder(host) + .setHttpClientConfigCallback(hc -> hc + .setDefaultCredentialsProvider(credsProv) + .setSSLContext(sslContext) + ) + .build(); + ObjectMapper mapper = JsonMapper.builder() + .addModule(new JavaTimeModule()) + .build(); + ElasticsearchTransport transport = new RestClientTransport(restClient, + new JacksonJsonpMapper(mapper)); + ElasticsearchClient esClient = new ElasticsearchClient(transport); + + // Creating the indexes + createSimpleIndex(esClient, USERS); + createIndexWithDateMapping(esClient, ARTICLES); + createIndexWithDateMapping(esClient, COMMENTS); + + return esClient; + } + + private void createSimpleIndex(ElasticsearchClient esClient, String index) throws IOException { + BooleanResponse indexRes = esClient.indices().exists(ex -> ex.index(index)); + if (!indexRes.value()) { + esClient.indices().create(c -> c + .index(index)); + } + } + + private void createIndexWithDateMapping(ElasticsearchClient esClient, String index) throws IOException { + BooleanResponse indexRes = esClient.indices().exists(ex -> ex.index(index)); + if (!indexRes.value()) { + esClient.indices().create(c -> c + .index(index) + .mappings(m -> m + .properties("createdAt", p -> p + .date(d -> d)) + .properties("updatedAt", p -> p + .date(d -> d)))); + + } + } +} diff --git a/examples/realworld-app/rw-database/src/test/java/realworld/db/UserServiceTest.java b/examples/realworld-app/rw-database/src/test/java/realworld/db/UserServiceTest.java new file mode 100644 index 000000000..dde02c678 --- /dev/null +++ b/examples/realworld-app/rw-database/src/test/java/realworld/db/UserServiceTest.java @@ -0,0 +1,64 @@ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package realworld.db; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; +import realworld.document.user.LoginDTO; +import realworld.document.user.RegisterDTO; +import realworld.document.user.User; +import realworld.document.user.UserDTO; + +import java.io.IOException; +import java.util.Objects; + +// This test uses test container, therefore the Docker engine needs to be installed to run it +// The testcontainer will take ~30 seconds to start +@TestPropertySource(locations = "classpath:test.properties") +@SpringBootTest(classes = {UserService.class, UserServiceTest.class, ElasticClientTest.class}) +public class UserServiceTest { + + @Autowired + private UserService service; + + @Test + public void testCreateUpdateUser() throws IOException { + RegisterDTO register = new RegisterDTO("user", "mail", "pw"); + User result = service.newUser(register); + assert (result.username().equals(register.username())); + assert (result.email().equals(register.email())); + assert (Objects.nonNull(result.token())); + String token = "Token " + result.token(); + + LoginDTO login = new LoginDTO("mail", "pw"); + result = service.authenticateUser(login); + assert (result.username().equals(register.username())); + + UserDTO update = new UserDTO("new-user", "mail", "", "bio", "image"); + result = service.updateUser(update, token); + assert (result.username().equals(update.username())); + assert (result.email().equals(update.email())); + assert (result.bio().equals(update.bio())); + assert (result.image().equals(update.image())); + } +} diff --git a/examples/realworld-app/rw-database/src/test/resources/test.properties b/examples/realworld-app/rw-database/src/test/resources/test.properties new file mode 100644 index 000000000..3b3058aed --- /dev/null +++ b/examples/realworld-app/rw-database/src/test/resources/test.properties @@ -0,0 +1,4 @@ +### +# Test properties +### +jwt.signing.key=c3VjaGFteXN0ZXJ5b3Vyc3VwZXJzZWNyZXR3b3c= diff --git a/examples/realworld-app/rw-logo.png b/examples/realworld-app/rw-logo.png new file mode 100644 index 000000000..99e5ed76a Binary files /dev/null and b/examples/realworld-app/rw-logo.png differ diff --git a/examples/realworld-app/rw-rest/build.gradle b/examples/realworld-app/rw-rest/build.gradle new file mode 100644 index 000000000..8b17e2b26 --- /dev/null +++ b/examples/realworld-app/rw-rest/build.gradle @@ -0,0 +1,22 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.2.1' + id 'io.spring.dependency-management' version '1.1.4' +} + +group = 'realworldapp' +version = '0.0.1-SNAPSHOT' + +java { + sourceCompatibility = '21' +} + +repositories { + mavenCentral() +} + +dependencies { + implementation('org.springframework.boot:spring-boot-starter-web:3.2.0') + implementation('realworldapp:rw-database') +} + diff --git a/examples/realworld-app/rw-rest/src/main/java/realworld/rest/ArticleController.java b/examples/realworld-app/rw-rest/src/main/java/realworld/rest/ArticleController.java new file mode 100644 index 000000000..9e207c7e2 --- /dev/null +++ b/examples/realworld-app/rw-rest/src/main/java/realworld/rest/ArticleController.java @@ -0,0 +1,188 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package realworld.rest; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import realworld.db.ArticleService; +import realworld.db.CommentService; +import realworld.db.UserService; +import realworld.document.article.*; +import realworld.document.comment.Comment; +import realworld.document.comment.CommentCreationDTO; +import realworld.document.comment.CommentDTO; +import realworld.document.comment.CommentsDTO; +import realworld.document.exception.ResourceNotFoundException; +import realworld.document.user.Author; +import realworld.document.user.User; +import realworld.utils.UserIdPair; + +import java.io.IOException; +import java.util.Optional; + +import static realworld.utils.Utility.isNullOrBlank; + +@CrossOrigin +@RestController +@RequestMapping("/articles") +public class ArticleController { + + private final ArticleService articleService; + private final UserService userService; + private final CommentService commentService; + + Logger logger = LoggerFactory.getLogger(UserController.class); + + @Autowired + public ArticleController(ArticleService articleService, UserService userService, + CommentService commentService) { + this.articleService = articleService; + this.userService = userService; + this.commentService = commentService; + } + + @PostMapping() + public ResponseEntity newArticle(@RequestBody ArticleCreationDTO req, @RequestHeader( + "Authorization") String auth) throws IOException { + UserIdPair userPair = userService.findUserByToken(auth); + Author author = new Author(userPair.user(), false); + + Article res = articleService.newArticle(req, author); + logger.debug("Created new article: {}", res.slug()); + return ResponseEntity.ok(new ArticleDTO(res)); + } + + @GetMapping("/{slug}") + public ResponseEntity findArticleBySlug(@PathVariable String slug) throws IOException { + Article res = Optional.ofNullable(articleService.findArticleBySlug(slug)) + .orElseThrow(() -> new ResourceNotFoundException("Article not found")) + .article(); + logger.debug("Retrieved article: {}", slug); + return ResponseEntity.ok(new ArticleDTO(res)); + } + + @GetMapping() + public ResponseEntity findArticles(@RequestParam(required = false) String tag, + @RequestParam(required = false) String author, + @RequestParam(required = false) String favorited, + @RequestParam(required = false) Integer limit, + @RequestParam(required = false) Integer offset, + @RequestHeader(value = "Authorization", required = + false) String auth) throws IOException { + Optional user = Optional.empty(); + if (!isNullOrBlank(auth)) { + user = Optional.of(userService.findUserByToken(auth).user()); + } + ArticlesDTO res = articleService.findArticles(tag, author, favorited, limit, offset, user); + logger.debug("Returned article list"); + return ResponseEntity.ok(res); + } + + @GetMapping("/feed") + public ResponseEntity generateFeed(@RequestHeader("Authorization") String auth) throws IOException { + User user = userService.findUserByToken(auth).user(); + + ArticlesDTO res = articleService.generateArticleFeed(user); + logger.debug("Generated feed"); + return ResponseEntity.ok(res); + } + + @PostMapping("/{slug}/favorite") + public ResponseEntity markArticleAsFavorite(@PathVariable String slug, @RequestHeader( + "Authorization") String auth) throws IOException { + String username = userService.findUserByToken(auth).user().username(); + + Article res = articleService.markArticleAsFavorite(slug, username); + logger.debug("Set article: {} as favorite", slug); + return ResponseEntity.ok(new ArticleDTO(res)); + } + + @DeleteMapping("/{slug}/favorite") + public ResponseEntity removeArticleFromFavorite(@PathVariable String slug, @RequestHeader( + "Authorization") String auth) throws IOException { + String username = userService.findUserByToken(auth).user().username(); + + Article res = articleService.removeArticleFromFavorite(slug, username); + logger.debug("Removed article: {} from favorites", slug); + return ResponseEntity.ok(new ArticleDTO(res)); + } + + @PutMapping("/{slug}") + public ResponseEntity updateArticle(@RequestBody ArticleUpdateDTO req, + @PathVariable String slug, @RequestHeader( + "Authorization") String auth) throws IOException { + UserIdPair userPair = userService.findUserByToken(auth); + Author author = new Author(userPair.user(), false); + + ArticleDTO res = articleService.updateArticle(req, slug, author); + logger.debug("Updated article: {}", slug); + return ResponseEntity.ok(res); + } + + @DeleteMapping("/{slug}") + public ResponseEntity deleteArticle(@PathVariable String slug, + @RequestHeader("Authorization") String auth) throws IOException { + UserIdPair userPair = userService.findUserByToken(auth); + Author author = new Author(userPair.user(), false); + + articleService.deleteArticle(slug, author); + logger.debug("Deleted article: {}", slug); + return ResponseEntity.ok().build(); + } + + @PostMapping("/{slug}/comments") + public ResponseEntity commentArticle(@PathVariable String slug, + @RequestBody CommentCreationDTO comment, + @RequestHeader("Authorization") String auth) throws IOException { + // Checking if the article exists + articleService.findArticleBySlug(slug); + // Getting the comment's author + User user = userService.findUserByToken(auth).user(); + + Comment res = commentService.newComment(comment, slug, user); + logger.debug("Commented article: {}", slug); + return ResponseEntity.ok(new CommentDTO(res)); + } + + @GetMapping("/{slug}/comments") + public ResponseEntity allCommentsByArticle(@PathVariable String slug, @RequestHeader( + value = "Authorization", required = false) String auth) throws IOException { + Optional user = Optional.empty(); + if (!isNullOrBlank(auth)) { + user = Optional.of(userService.findUserByToken(auth).user()); + } + CommentsDTO res = commentService.findAllCommentsByArticle(slug, user); + logger.debug("Comments for article: {}", slug); + return ResponseEntity.ok(res); + } + + @DeleteMapping("/{slug}/comments/{commentId}") + public ResponseEntity deleteComment(@PathVariable String slug, @PathVariable String commentId, + @RequestHeader("Authorization") String auth) throws IOException { + String username = userService.findUserByToken(auth).user().username(); + + commentService.deleteComment(commentId, username); + logger.debug("Deleted comment: {} from article {}", commentId, slug); + return ResponseEntity.ok().build(); + } +} diff --git a/examples/realworld-app/rw-rest/src/main/java/realworld/rest/ProfileController.java b/examples/realworld-app/rw-rest/src/main/java/realworld/rest/ProfileController.java new file mode 100644 index 000000000..65ad334ca --- /dev/null +++ b/examples/realworld-app/rw-rest/src/main/java/realworld/rest/ProfileController.java @@ -0,0 +1,69 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package realworld.rest; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import realworld.db.UserService; +import realworld.document.user.Profile; + +import java.io.IOException; + +@CrossOrigin +@RestController +@RequestMapping("/profiles") +public class ProfileController { + + private UserService service; + + Logger logger = LoggerFactory.getLogger(UserController.class); + + @Autowired + public ProfileController(UserService service) { + this.service = service; + } + + @GetMapping("/{username}") + public ResponseEntity get(@PathVariable String username, + @RequestHeader("Authorization") String auth) throws IOException { + Profile res = service.findUserProfile(username, auth); + logger.debug("Returning profile for user {}", res.username()); + return ResponseEntity.ok(res); + } + + @PostMapping("/{username}/follow") + public ResponseEntity follow(@PathVariable String username, + @RequestHeader("Authorization") String auth) throws IOException { + Profile res = service.followUser(username, auth); + logger.debug("Following user {}", res.username()); + return ResponseEntity.ok(res); + } + + @DeleteMapping("/{username}/follow") + public ResponseEntity unfollow(@PathVariable String username, + @RequestHeader("Authorization") String auth) throws IOException { + Profile res = service.unfollowUser(username, auth); + logger.debug("Unfollowing user {}", res.username()); + return ResponseEntity.ok(res); + } +} diff --git a/examples/realworld-app/rw-rest/src/main/java/realworld/rest/TagsController.java b/examples/realworld-app/rw-rest/src/main/java/realworld/rest/TagsController.java new file mode 100644 index 000000000..7fd8452d7 --- /dev/null +++ b/examples/realworld-app/rw-rest/src/main/java/realworld/rest/TagsController.java @@ -0,0 +1,55 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package realworld.rest; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import realworld.db.ArticleService; +import realworld.document.article.TagsDTO; + +import java.io.IOException; + +@CrossOrigin +@RestController +@RequestMapping("/tags") +public class TagsController { + + private final ArticleService service; + + Logger logger = LoggerFactory.getLogger(UserController.class); + + @Autowired + public TagsController(ArticleService service) { + this.service = service; + } + + @GetMapping() + public ResponseEntity get() throws IOException { + TagsDTO res = service.findAllTags(); + logger.debug("Retrieved all tags"); + return ResponseEntity.ok(res); + } +} diff --git a/examples/realworld-app/rw-rest/src/main/java/realworld/rest/UserController.java b/examples/realworld-app/rw-rest/src/main/java/realworld/rest/UserController.java new file mode 100644 index 000000000..2d6e7def5 --- /dev/null +++ b/examples/realworld-app/rw-rest/src/main/java/realworld/rest/UserController.java @@ -0,0 +1,79 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package realworld.rest; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import realworld.db.UserService; +import realworld.document.user.LoginDTO; +import realworld.document.user.RegisterDTO; +import realworld.document.user.User; +import realworld.document.user.UserDTO; + +import java.io.IOException; + +@CrossOrigin +@RestController +@RequestMapping() +public class UserController { + + private UserService service; + + Logger logger = LoggerFactory.getLogger(UserController.class); + + @Autowired + public UserController(UserService service) { + this.service = service; + } + + @PostMapping("/users") + public ResponseEntity register(@RequestBody RegisterDTO req) throws IOException { + User res = service.newUser(req); + logger.debug("Registered new user {}", req.username()); + return ResponseEntity.ok(new UserDTO(res)); + } + + @PostMapping("users/login") + public ResponseEntity login(@RequestBody LoginDTO req) throws IOException { + User res = service.authenticateUser(req); + logger.debug("User {} logged in", res.username()); + return ResponseEntity.ok(new UserDTO(res)); + } + + @GetMapping("/user") + public ResponseEntity find(@RequestHeader("Authorization") String auth) throws IOException { + User res = service.findUserByToken(auth).user(); + logger.debug("Returning info about user {}", res.username()); + return ResponseEntity.ok(new UserDTO(res)); + + } + + @PutMapping("/user") + public ResponseEntity update(@RequestBody UserDTO req, + @RequestHeader("Authorization") String auth) throws IOException { + User res = service.updateUser(req, auth); + logger.debug("Updated info for user {}", req.username()); + return ResponseEntity.ok(new UserDTO(res)); + + } +} diff --git a/examples/realworld-app/rw-rest/src/main/java/realworld/rest/error/RestError.java b/examples/realworld-app/rw-rest/src/main/java/realworld/rest/error/RestError.java new file mode 100644 index 000000000..1390744a4 --- /dev/null +++ b/examples/realworld-app/rw-rest/src/main/java/realworld/rest/error/RestError.java @@ -0,0 +1,33 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package realworld.rest.error; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonTypeInfo.As; +import com.fasterxml.jackson.annotation.JsonTypeInfo.Id; +import com.fasterxml.jackson.annotation.JsonTypeName; + +import java.util.List; + +@JsonTypeName("errors") +@JsonTypeInfo(include = As.WRAPPER_OBJECT, use = Id.NAME) +public record RestError(List body) { +} + diff --git a/examples/realworld-app/rw-rest/src/main/java/realworld/rest/error/RestExceptionHandler.java b/examples/realworld-app/rw-rest/src/main/java/realworld/rest/error/RestExceptionHandler.java new file mode 100644 index 000000000..b09b505e2 --- /dev/null +++ b/examples/realworld-app/rw-rest/src/main/java/realworld/rest/error/RestExceptionHandler.java @@ -0,0 +1,79 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package realworld.rest.error; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; +import realworld.document.exception.ResourceAlreadyExistsException; +import realworld.document.exception.ResourceNotFoundException; +import realworld.document.exception.UnauthorizedException; + +import java.io.IOException; +import java.util.List; + +@ControllerAdvice +public class RestExceptionHandler + extends ResponseEntityExceptionHandler { + + @ExceptionHandler(value + = {IOException.class}) + protected ResponseEntity 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); + } + + @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); + } +} diff --git a/examples/realworld-app/rw-server/build.gradle b/examples/realworld-app/rw-server/build.gradle new file mode 100644 index 000000000..42d7057b4 --- /dev/null +++ b/examples/realworld-app/rw-server/build.gradle @@ -0,0 +1,21 @@ +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') +} 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..16d03d352 --- /dev/null +++ b/examples/realworld-app/rw-server/src/main/java/realworld/SpringBootApp.java @@ -0,0 +1,34 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package realworld; + +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..a7b177a67 --- /dev/null +++ b/examples/realworld-app/rw-server/src/main/java/realworld/config/DefaultProperties.java @@ -0,0 +1,39 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package realworld.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..c4b6a564c --- /dev/null +++ b/examples/realworld-app/rw-server/src/main/resources/application.properties @@ -0,0 +1,13 @@ +### +# Application Settings +### +server.port=8080 +server.address=localhost +logging.level.org.springframework.web=DEBUG +jwt.signing.key=c3VjaGFteXN0ZXJ5b3Vyc3VwZXJzZWNyZXR3b3c= +### +# 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..846924785 --- /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") +