├── .dockerignore ├── .env.template ├── .gitignore ├── .mvn └── wrapper │ └── maven-wrapper.properties ├── 1-system-diagram.svg ├── 2-registered-user-in-keycloak.png ├── 3-registered-user-in-keycloak-attributes.png ├── Dockerfile ├── README.md ├── docker-compose.yaml ├── docs └── set-up-keycloak │ ├── 1-create-realm.png │ ├── 2-create-client-enable-client-authentication.png │ ├── 3-create-client-copy-client-secret.png │ ├── 4-create-user-profile-attributes-tab.png │ ├── 5-create-user-profile-attributes-create-attribute.png │ ├── 6-create-admins-group.png │ ├── 7-create-admin-user.png │ ├── 8-create-admin-user-set-password.png │ └── README.md ├── favicon.ico ├── logo.png ├── mvnw ├── mvnw.cmd ├── pom.xml └── src ├── main ├── java │ └── com │ │ └── marcusmonteirodesouza │ │ └── realworld │ │ ├── RealworldApplication.java │ │ └── api │ │ ├── articles │ │ ├── controllers │ │ │ ├── ArticlesController.java │ │ │ ├── TagsController.java │ │ │ └── dto │ │ │ │ ├── AddCommentToArticleRequest.java │ │ │ │ ├── ArticleResponse.java │ │ │ │ ├── CommentResponse.java │ │ │ │ ├── CreateArticleRequest.java │ │ │ │ ├── ListOfTagsResponse.java │ │ │ │ ├── MultipleArticlesResponse.java │ │ │ │ ├── MultipleCommentsResponse.java │ │ │ │ └── UpdateArticleRequest.java │ │ ├── models │ │ │ ├── Article.java │ │ │ ├── Comment.java │ │ │ ├── Favorite.java │ │ │ └── Tag.java │ │ ├── repositories │ │ │ ├── articles │ │ │ │ ├── ArticlesRepository.java │ │ │ │ └── specifications │ │ │ │ │ └── ArticlesSpecifications.java │ │ │ ├── comments │ │ │ │ └── CommentsRepository.java │ │ │ └── tags │ │ │ │ └── TagsRepository.java │ │ └── services │ │ │ ├── ArticlesService.java │ │ │ └── parameterobjects │ │ │ ├── ArticleCreate.java │ │ │ ├── ArticleUpdate.java │ │ │ └── ArticlesList.java │ │ ├── authentication │ │ ├── AuthenticationFacade.java │ │ └── IAuthenticationFacade.java │ │ ├── exceptionhandlers │ │ ├── RestResponseEntityExceptionHandler.java │ │ └── dto │ │ │ └── ErrorResponse.java │ │ ├── exceptions │ │ ├── AlreadyExistsException.java │ │ └── ForbiddenException.java │ │ ├── profiles │ │ ├── controllers │ │ │ ├── ProfilesController.java │ │ │ └── dto │ │ │ │ └── ProfileResponse.java │ │ ├── models │ │ │ ├── Follow.java │ │ │ └── Profile.java │ │ ├── repositories │ │ │ └── FollowsRepository.java │ │ └── services │ │ │ └── ProfilesService.java │ │ ├── security │ │ └── SecurityConfig.java │ │ └── users │ │ ├── controllers │ │ ├── UsersController.java │ │ └── dto │ │ │ ├── LoginRequest.java │ │ │ ├── RegisterUserRequest.java │ │ │ ├── UpdateUserRequest.java │ │ │ └── UserResponse.java │ │ ├── models │ │ └── User.java │ │ └── services │ │ └── users │ │ ├── UsersService.java │ │ └── parameterobjects │ │ └── UserUpdate.java └── resources │ └── application.properties └── test └── java └── com └── marcusmonteirodesouza └── realworld └── RealworldApplicationTests.java /.dockerignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | appdb-data 3 | keycloakdb-data 4 | target 5 | .env* -------------------------------------------------------------------------------- /.env.template: -------------------------------------------------------------------------------- 1 | KEYCLOAK_REALM=realworld 2 | KEYCLOAK_REALM_ADMIN=realworld_admin 3 | KEYCLOAK_REALM_ADMIN_PASSWORD=realworld_admin 4 | KEYCLOAK_REALM_CLIENT_ID=external-client 5 | KEYCLOAK_REALM_CLIENT_SECRET=top-secret 6 | KEYCLOAK_SERVER_URL=http://localhost:8081 7 | APP_DB=realworld 8 | APP_DB_HOSTNAME=localhost 9 | APP_DB_PASSWORD=postgres 10 | APP_DB_PORT=5432 11 | APP_DB_USERNAME=postgres 12 | PORT=8080 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | target/ 3 | !.mvn/wrapper/maven-wrapper.jar 4 | !**/src/main/**/target/ 5 | !**/src/test/**/target/ 6 | 7 | ### STS ### 8 | .apt_generated 9 | .classpath 10 | .factorypath 11 | .project 12 | .settings 13 | .springBeans 14 | .sts4-cache 15 | 16 | ### IntelliJ IDEA ### 17 | .idea 18 | *.iws 19 | *.iml 20 | *.ipr 21 | 22 | ### NetBeans ### 23 | /nbproject/private/ 24 | /nbbuild/ 25 | /dist/ 26 | /nbdist/ 27 | /.nb-gradle/ 28 | build/ 29 | !**/src/main/**/build/ 30 | !**/src/test/**/build/ 31 | 32 | ### VS Code ### 33 | .vscode/ 34 | 35 | # .env 36 | .env 37 | 38 | # DB Data 39 | appdb-data 40 | keycloakdb-data -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | # Licensed to the Apache Software Foundation (ASF) under one 2 | # or more contributor license agreements. See the NOTICE file 3 | # distributed with this work for additional information 4 | # regarding copyright ownership. The ASF licenses this file 5 | # to you under the Apache License, Version 2.0 (the 6 | # "License"); you may not use this file except in compliance 7 | # with the License. You may obtain a copy of the License at 8 | # 9 | # https://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, 12 | # software distributed under the License is distributed on an 13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | # KIND, either express or implied. See the License for the 15 | # specific language governing permissions and limitations 16 | # under the License. 17 | wrapperVersion=3.3.1 18 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.6/apache-maven-3.9.6-bin.zip 19 | -------------------------------------------------------------------------------- /1-system-diagram.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /2-registered-user-in-keycloak.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcusmonteirodesouza/realworld-backend-spring-boot-java-keycloak-postgresql/c1c00e1a3aa1da2e462744d21842bf9e31b252ed/2-registered-user-in-keycloak.png -------------------------------------------------------------------------------- /3-registered-user-in-keycloak-attributes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcusmonteirodesouza/realworld-backend-spring-boot-java-keycloak-postgresql/c1c00e1a3aa1da2e462744d21842bf9e31b252ed/3-registered-user-in-keycloak-attributes.png -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM eclipse-temurin:21 as build-env 2 | 3 | ADD . /app 4 | 5 | WORKDIR /app 6 | 7 | RUN ./mvnw clean install -Dmaven.test.skip 8 | 9 | FROM eclipse-temurin:21-ubi9-minimal 10 | 11 | COPY --from=build-env /app/target/realworld-0.0.1-SNAPSHOT.jar /app/ 12 | 13 | WORKDIR /app 14 | 15 | ENTRYPOINT [ "java", "-jar", "realworld-0.0.1-SNAPSHOT.jar" ] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![RealWorld Example App](logo.png) 2 | 3 | > ### [Spring Boot](https://spring.io/projects/spring-boot) codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the [RealWorld](https://github.com/gothinkster/realworld) spec and API. 4 | 5 | ### [Demo](https://demo.realworld.io/)    [RealWorld](https://github.com/gothinkster/realworld) 6 | 7 | This codebase was created to demonstrate a fully fledged backend application built with **[Spring Boot](https://spring.io/projects/spring-boot)** including CRUD operations, authentication, routing, pagination, and more. 8 | 9 | We've gone to great lengths to adhere to the **[Spring Boot](https://spring.io/projects/spring-boot)** community styleguides & best practices. 10 | 11 | For more information on how to this works with other frontends/backends, head over to the [RealWorld](https://github.com/gothinkster/realworld) repo. 12 | 13 | # How it works 14 | 15 | ![System Diagram](1-system-diagram.svg) 16 | 17 | - It uses a [Modular Monolith architecture](https://www.milanjovanovic.tech/blog/what-is-a-modular-monolith) with clearly defined boundaries and independent modules ([users](src/main/java/com/marcusmonteirodesouza/realworld/api/users), [profiles](src/main/java/com/marcusmonteirodesouza/realworld/api/profiles), and [articles](src/main/java/com/marcusmonteirodesouza/realworld/api/articles)). 18 | - It uses [Spring Security](https://spring.io/projects/spring-security) and [Keycloak](https://www.keycloak.org/) for user registration, authentication, and authorization. It provides good examples of usage of the [Keycloak Admin REST Client](https://mvnrepository.com/artifact/org.keycloak/keycloak-admin-client), including User creation and JWT generation ([See](src/main/java/com/marcusmonteirodesouza/realworld/api/users/services/users/UsersService.java)). 19 | - Both Keycloak and the Realworld backend application use [PostgreSQL](https://www.postgresql.org/) as their database/datasource. The Realworld backend application uses [Jakarta Persistence](https://jakarta.ee/learn/docs/jakartaee-tutorial/current/persist/persistence-intro/persistence-intro.html) (JPA) as an object/relational mapping facility (ORM). 20 | - It handles exceptions in a [centralized way](src/main/java/com/marcusmonteirodesouza/realworld/api/exceptionhandlers/RestResponseEntityExceptionHandler.java), extending the [ResponseEntityExceptionHandler](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandler.html) class. 21 | 22 | # Getting started 23 | 24 | 1. Run `cp .env.template .env`. The `.env` file contains the environment variables used by both Keycloak and the Realworld backend application, including secrets. 25 | 26 | ## Set up Keycloak 27 | 28 | Follow the [documentation](docs/set-up-keycloak) 29 | 30 | ## Run the application 31 | 32 | 1. Run `docker compose up`. 33 | 1. When you register or update an user, you can see it listed and also it's details on the [Keycloak admin console](http://localhost:8081). 34 | ![Registered User in Keycloak](./2-registered-user-in-keycloak.png) 35 | ![Registered User in Keycloak - Attributes](./3-registered-user-in-keycloak-attributes.png) 36 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | app: 3 | build: 4 | context: . 5 | dockerfile: Dockerfile 6 | ports: 7 | - "${PORT}:${PORT}" 8 | environment: 9 | - KEYCLOAK_REALM=${KEYCLOAK_REALM} 10 | - KEYCLOAK_REALM_ADMIN=${KEYCLOAK_REALM_ADMIN} 11 | - KEYCLOAK_REALM_ADMIN_PASSWORD=${KEYCLOAK_REALM_ADMIN_PASSWORD} 12 | - KEYCLOAK_REALM_CLIENT_ID=${KEYCLOAK_REALM_CLIENT_ID} 13 | - KEYCLOAK_REALM_CLIENT_SECRET=${KEYCLOAK_REALM_CLIENT_SECRET} 14 | - KEYCLOAK_SERVER_URL=http://keycloak:8080 15 | - APP_DB=${APP_DB} 16 | - APP_DB_HOSTNAME=appdb 17 | - APP_DB_PASSWORD=${APP_DB_PASSWORD} 18 | - APP_DB_PORT=${APP_DB_PORT} 19 | - APP_DB_USERNAME=${APP_DB_USERNAME} 20 | - PORT=${PORT} 21 | depends_on: 22 | keycloak: 23 | condition: service_started 24 | appdb: 25 | condition: service_healthy 26 | appdb: 27 | image: "postgres:15" 28 | ports: 29 | - ${APP_DB_PORT}:${APP_DB_PORT} 30 | environment: 31 | - POSTGRES_USER=${APP_DB_USERNAME} 32 | - POSTGRES_PASSWORD=${APP_DB_PASSWORD} 33 | - POSTGRES_DB=${APP_DB} 34 | volumes: 35 | - ./appdb-data:/var/lib/postgresql/data 36 | healthcheck: 37 | test: ["CMD-SHELL", "pg_isready"] 38 | interval: 10s 39 | timeout: 5s 40 | retries: 5 41 | keycloak: 42 | image: "quay.io/keycloak/keycloak" 43 | command: ["start-dev"] 44 | ports: 45 | - 8081:8080 46 | environment: 47 | - KC_DB=postgres 48 | - KC_DB_URL=jdbc:postgresql://keycloakdb:5432/postgres 49 | # - KC_LOG_LEVEL=DEBUG 50 | - KC_DB_USERNAME=postgres 51 | - KC_DB_PASSWORD=postgres 52 | - KEYCLOAK_ADMIN=admin 53 | - KEYCLOAK_ADMIN_PASSWORD=admin 54 | depends_on: 55 | keycloakdb: 56 | condition: service_healthy 57 | keycloakdb: 58 | image: "postgres:15" 59 | environment: 60 | - POSTGRES_USER=postgres 61 | - POSTGRES_PASSWORD=postgres 62 | - POSTGRES_DB=postgres 63 | volumes: 64 | - ./keycloakdb-data:/var/lib/postgresql/data 65 | healthcheck: 66 | test: ["CMD-SHELL", "pg_isready"] 67 | interval: 10s 68 | timeout: 5s 69 | retries: 5 70 | -------------------------------------------------------------------------------- /docs/set-up-keycloak/1-create-realm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcusmonteirodesouza/realworld-backend-spring-boot-java-keycloak-postgresql/c1c00e1a3aa1da2e462744d21842bf9e31b252ed/docs/set-up-keycloak/1-create-realm.png -------------------------------------------------------------------------------- /docs/set-up-keycloak/2-create-client-enable-client-authentication.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcusmonteirodesouza/realworld-backend-spring-boot-java-keycloak-postgresql/c1c00e1a3aa1da2e462744d21842bf9e31b252ed/docs/set-up-keycloak/2-create-client-enable-client-authentication.png -------------------------------------------------------------------------------- /docs/set-up-keycloak/3-create-client-copy-client-secret.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcusmonteirodesouza/realworld-backend-spring-boot-java-keycloak-postgresql/c1c00e1a3aa1da2e462744d21842bf9e31b252ed/docs/set-up-keycloak/3-create-client-copy-client-secret.png -------------------------------------------------------------------------------- /docs/set-up-keycloak/4-create-user-profile-attributes-tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcusmonteirodesouza/realworld-backend-spring-boot-java-keycloak-postgresql/c1c00e1a3aa1da2e462744d21842bf9e31b252ed/docs/set-up-keycloak/4-create-user-profile-attributes-tab.png -------------------------------------------------------------------------------- /docs/set-up-keycloak/5-create-user-profile-attributes-create-attribute.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcusmonteirodesouza/realworld-backend-spring-boot-java-keycloak-postgresql/c1c00e1a3aa1da2e462744d21842bf9e31b252ed/docs/set-up-keycloak/5-create-user-profile-attributes-create-attribute.png -------------------------------------------------------------------------------- /docs/set-up-keycloak/6-create-admins-group.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcusmonteirodesouza/realworld-backend-spring-boot-java-keycloak-postgresql/c1c00e1a3aa1da2e462744d21842bf9e31b252ed/docs/set-up-keycloak/6-create-admins-group.png -------------------------------------------------------------------------------- /docs/set-up-keycloak/7-create-admin-user.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcusmonteirodesouza/realworld-backend-spring-boot-java-keycloak-postgresql/c1c00e1a3aa1da2e462744d21842bf9e31b252ed/docs/set-up-keycloak/7-create-admin-user.png -------------------------------------------------------------------------------- /docs/set-up-keycloak/8-create-admin-user-set-password.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcusmonteirodesouza/realworld-backend-spring-boot-java-keycloak-postgresql/c1c00e1a3aa1da2e462744d21842bf9e31b252ed/docs/set-up-keycloak/8-create-admin-user-set-password.png -------------------------------------------------------------------------------- /docs/set-up-keycloak/README.md: -------------------------------------------------------------------------------- 1 | # Set up [Keycloak](https://www.keycloak.org/) 2 | 3 | 1. Run `docker compose up keycloak`. 4 | 1. Sign in into the [admin console](http://localhost:8081). Both username and password are `admin`, as set in the [`docker-compose.yaml`](../../docker-compose.yaml) using the [`KEYCLOAK_ADMIN` and `KEYCLOAK_ADMIN_PASSWORD` environment variables](https://www.keycloak.org/server/configuration#_creating_the_initial_admin_user). 5 | 1. [Create a new Realm](https://www.keycloak.org/docs/latest/server_admin/#proc-creating-a-realm_server_administration_guide). The realm should be named after the value of the `KEYCLOAK_REALM` environment variable (e.g. `realworld`). 6 | ![Create Realm](1-create-realm.png) 7 | 1. [Create a new OIDC client](https://wjw465150.gitbooks.io/keycloak-documentation/content/server_admin/topics/clients/client-oidc.html). The client should be named after the value `KEYCLOAK_REALM_CLIENT_ID` environment variable (e.g. `external-client`). Enable `Client authentication`. After you save the client, go to the `Credentials` tab, copy the `Client Secret` and set the `KEYCLOAK_REALM_CLIENT_SECRET` environment variable to it's value. 8 | ![Create Client - Enable Client Authentication](2-create-client-enable-client-authentication.png) 9 | ![Create Client - Enable Client Authentication](3-create-client-copy-client-secret.png) 10 | 1. Go to `Realm settings`, then go to the `User profile` tab. Delete the `firstName` and `lastName` attributes and add `bio` and `image` as attributes. When creating the new attributes, set `Required field` to `Off` and check all the `Permission` checkboxes. 11 | ![Create User Profile Attributes - Tab](4-create-user-profile-attributes-tab.png) 12 | ![Create User Profile Attributes - Create Attribute](5-create-user-profile-attributes-create-attribute.png) 13 | 1. [Create an admins group](https://www.keycloak.org/docs/latest/server_admin/#proc-managing-groups_server_administration_guide) and assign all `users` related roles to it. 14 | ![Create Admins Group](6-create-admins-group.png) 15 | 1. [Create an user](https://www.keycloak.org/docs/latest/server_admin/#proc-creating-user_server_administration_guide) to serve as the `admin` user with which the `Realworld backend application` will use to connect to Keycloak. It's username should have the same value as the `KEYCLOAK_REALM_ADMIN` environment variable, and it's password should have the same value as the `KEYCLOAK_REALM_ADMIN_PASSWORD` environment variable. Remove any `Required user actions`. Set `Email verified` to `On`. Add the created user to the `admins` group. Go to the `Credentials` tab to set up the password, and set `Temporary` to `Off`. 16 | ![Create Admin User](7-create-admin-user.png) 17 | ![Create Admin User - Set Password](8-create-admin-user-set-password.png) 18 | 1. Sign out of the admin console. 19 | 1. Stop the containers by running `CTRL + C` or `docker compose down`. 20 | -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcusmonteirodesouza/realworld-backend-spring-boot-java-keycloak-postgresql/c1c00e1a3aa1da2e462744d21842bf9e31b252ed/favicon.ico -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcusmonteirodesouza/realworld-backend-spring-boot-java-keycloak-postgresql/c1c00e1a3aa1da2e462744d21842bf9e31b252ed/logo.png -------------------------------------------------------------------------------- /mvnw: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # ---------------------------------------------------------------------------- 3 | # Licensed to the Apache Software Foundation (ASF) under one 4 | # or more contributor license agreements. See the NOTICE file 5 | # distributed with this work for additional information 6 | # regarding copyright ownership. The ASF licenses this file 7 | # to you under the Apache License, Version 2.0 (the 8 | # "License"); you may not use this file except in compliance 9 | # with the License. You may obtain a copy of the License at 10 | # 11 | # https://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, 14 | # software distributed under the License is distributed on an 15 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 16 | # KIND, either express or implied. See the License for the 17 | # specific language governing permissions and limitations 18 | # under the License. 19 | # ---------------------------------------------------------------------------- 20 | 21 | # ---------------------------------------------------------------------------- 22 | # Apache Maven Wrapper startup batch script, version 3.3.1 23 | # 24 | # Optional ENV vars 25 | # ----------------- 26 | # JAVA_HOME - location of a JDK home dir, required when download maven via java source 27 | # MVNW_REPOURL - repo url base for downloading maven distribution 28 | # MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven 29 | # MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output 30 | # ---------------------------------------------------------------------------- 31 | 32 | set -euf 33 | [ "${MVNW_VERBOSE-}" != debug ] || set -x 34 | 35 | # OS specific support. 36 | native_path() { printf %s\\n "$1"; } 37 | case "$(uname)" in 38 | CYGWIN* | MINGW*) 39 | [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" 40 | native_path() { cygpath --path --windows "$1"; } 41 | ;; 42 | esac 43 | 44 | # set JAVACMD and JAVACCMD 45 | set_java_home() { 46 | # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched 47 | if [ -n "${JAVA_HOME-}" ]; then 48 | if [ -x "$JAVA_HOME/jre/sh/java" ]; then 49 | # IBM's JDK on AIX uses strange locations for the executables 50 | JAVACMD="$JAVA_HOME/jre/sh/java" 51 | JAVACCMD="$JAVA_HOME/jre/sh/javac" 52 | else 53 | JAVACMD="$JAVA_HOME/bin/java" 54 | JAVACCMD="$JAVA_HOME/bin/javac" 55 | 56 | if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then 57 | echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 58 | echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 59 | return 1 60 | fi 61 | fi 62 | else 63 | JAVACMD="$( 64 | 'set' +e 65 | 'unset' -f command 2>/dev/null 66 | 'command' -v java 67 | )" || : 68 | JAVACCMD="$( 69 | 'set' +e 70 | 'unset' -f command 2>/dev/null 71 | 'command' -v javac 72 | )" || : 73 | 74 | if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then 75 | echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 76 | return 1 77 | fi 78 | fi 79 | } 80 | 81 | # hash string like Java String::hashCode 82 | hash_string() { 83 | str="${1:-}" h=0 84 | while [ -n "$str" ]; do 85 | char="${str%"${str#?}"}" 86 | h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) 87 | str="${str#?}" 88 | done 89 | printf %x\\n $h 90 | } 91 | 92 | verbose() { :; } 93 | [ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } 94 | 95 | die() { 96 | printf %s\\n "$1" >&2 97 | exit 1 98 | } 99 | 100 | # parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties 101 | while IFS="=" read -r key value; do 102 | case "${key-}" in 103 | distributionUrl) distributionUrl="${value-}" ;; 104 | distributionSha256Sum) distributionSha256Sum="${value-}" ;; 105 | esac 106 | done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties" 107 | [ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties" 108 | 109 | case "${distributionUrl##*/}" in 110 | maven-mvnd-*bin.*) 111 | MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ 112 | case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in 113 | *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; 114 | :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; 115 | :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; 116 | :Linux*x86_64*) distributionPlatform=linux-amd64 ;; 117 | *) 118 | echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 119 | distributionPlatform=linux-amd64 120 | ;; 121 | esac 122 | distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" 123 | ;; 124 | maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; 125 | *) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; 126 | esac 127 | 128 | # apply MVNW_REPOURL and calculate MAVEN_HOME 129 | # maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ 130 | [ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" 131 | distributionUrlName="${distributionUrl##*/}" 132 | distributionUrlNameMain="${distributionUrlName%.*}" 133 | distributionUrlNameMain="${distributionUrlNameMain%-bin}" 134 | MAVEN_HOME="$HOME/.m2/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" 135 | 136 | exec_maven() { 137 | unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : 138 | exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" 139 | } 140 | 141 | if [ -d "$MAVEN_HOME" ]; then 142 | verbose "found existing MAVEN_HOME at $MAVEN_HOME" 143 | exec_maven "$@" 144 | fi 145 | 146 | case "${distributionUrl-}" in 147 | *?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; 148 | *) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; 149 | esac 150 | 151 | # prepare tmp dir 152 | if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then 153 | clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } 154 | trap clean HUP INT TERM EXIT 155 | else 156 | die "cannot create temp dir" 157 | fi 158 | 159 | mkdir -p -- "${MAVEN_HOME%/*}" 160 | 161 | # Download and Install Apache Maven 162 | verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." 163 | verbose "Downloading from: $distributionUrl" 164 | verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" 165 | 166 | # select .zip or .tar.gz 167 | if ! command -v unzip >/dev/null; then 168 | distributionUrl="${distributionUrl%.zip}.tar.gz" 169 | distributionUrlName="${distributionUrl##*/}" 170 | fi 171 | 172 | # verbose opt 173 | __MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' 174 | [ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v 175 | 176 | # normalize http auth 177 | case "${MVNW_PASSWORD:+has-password}" in 178 | '') MVNW_USERNAME='' MVNW_PASSWORD='' ;; 179 | has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; 180 | esac 181 | 182 | if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then 183 | verbose "Found wget ... using wget" 184 | wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" 185 | elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then 186 | verbose "Found curl ... using curl" 187 | curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" 188 | elif set_java_home; then 189 | verbose "Falling back to use Java to download" 190 | javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" 191 | targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" 192 | cat >"$javaSource" <<-END 193 | public class Downloader extends java.net.Authenticator 194 | { 195 | protected java.net.PasswordAuthentication getPasswordAuthentication() 196 | { 197 | return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); 198 | } 199 | public static void main( String[] args ) throws Exception 200 | { 201 | setDefault( new Downloader() ); 202 | java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); 203 | } 204 | } 205 | END 206 | # For Cygwin/MinGW, switch paths to Windows format before running javac and java 207 | verbose " - Compiling Downloader.java ..." 208 | "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" 209 | verbose " - Running Downloader.java ..." 210 | "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" 211 | fi 212 | 213 | # If specified, validate the SHA-256 sum of the Maven distribution zip file 214 | if [ -n "${distributionSha256Sum-}" ]; then 215 | distributionSha256Result=false 216 | if [ "$MVN_CMD" = mvnd.sh ]; then 217 | echo "Checksum validation is not supported for maven-mvnd." >&2 218 | echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 219 | exit 1 220 | elif command -v sha256sum >/dev/null; then 221 | if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then 222 | distributionSha256Result=true 223 | fi 224 | elif command -v shasum >/dev/null; then 225 | if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then 226 | distributionSha256Result=true 227 | fi 228 | else 229 | echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 230 | echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 231 | exit 1 232 | fi 233 | if [ $distributionSha256Result = false ]; then 234 | echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 235 | echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 236 | exit 1 237 | fi 238 | fi 239 | 240 | # unzip and move 241 | if command -v unzip >/dev/null; then 242 | unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" 243 | else 244 | tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" 245 | fi 246 | printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url" 247 | mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" 248 | 249 | clean || : 250 | exec_maven "$@" 251 | -------------------------------------------------------------------------------- /mvnw.cmd: -------------------------------------------------------------------------------- 1 | <# : batch portion 2 | @REM ---------------------------------------------------------------------------- 3 | @REM Licensed to the Apache Software Foundation (ASF) under one 4 | @REM or more contributor license agreements. See the NOTICE file 5 | @REM distributed with this work for additional information 6 | @REM regarding copyright ownership. The ASF licenses this file 7 | @REM to you under the Apache License, Version 2.0 (the 8 | @REM "License"); you may not use this file except in compliance 9 | @REM with the License. You may obtain a copy of the License at 10 | @REM 11 | @REM https://www.apache.org/licenses/LICENSE-2.0 12 | @REM 13 | @REM Unless required by applicable law or agreed to in writing, 14 | @REM software distributed under the License is distributed on an 15 | @REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 16 | @REM KIND, either express or implied. See the License for the 17 | @REM specific language governing permissions and limitations 18 | @REM under the License. 19 | @REM ---------------------------------------------------------------------------- 20 | 21 | @REM ---------------------------------------------------------------------------- 22 | @REM Apache Maven Wrapper startup batch script, version 3.3.1 23 | @REM 24 | @REM Optional ENV vars 25 | @REM MVNW_REPOURL - repo url base for downloading maven distribution 26 | @REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven 27 | @REM MVNW_VERBOSE - true: enable verbose log; others: silence the output 28 | @REM ---------------------------------------------------------------------------- 29 | 30 | @IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) 31 | @SET __MVNW_CMD__= 32 | @SET __MVNW_ERROR__= 33 | @SET __MVNW_PSMODULEP_SAVE=%PSModulePath% 34 | @SET PSModulePath= 35 | @FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( 36 | IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) 37 | ) 38 | @SET PSModulePath=%__MVNW_PSMODULEP_SAVE% 39 | @SET __MVNW_PSMODULEP_SAVE= 40 | @SET __MVNW_ARG0_NAME__= 41 | @SET MVNW_USERNAME= 42 | @SET MVNW_PASSWORD= 43 | @IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*) 44 | @echo Cannot start maven from wrapper >&2 && exit /b 1 45 | @GOTO :EOF 46 | : end batch / begin powershell #> 47 | 48 | $ErrorActionPreference = "Stop" 49 | if ($env:MVNW_VERBOSE -eq "true") { 50 | $VerbosePreference = "Continue" 51 | } 52 | 53 | # calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties 54 | $distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl 55 | if (!$distributionUrl) { 56 | Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" 57 | } 58 | 59 | switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { 60 | "maven-mvnd-*" { 61 | $USE_MVND = $true 62 | $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" 63 | $MVN_CMD = "mvnd.cmd" 64 | break 65 | } 66 | default { 67 | $USE_MVND = $false 68 | $MVN_CMD = $script -replace '^mvnw','mvn' 69 | break 70 | } 71 | } 72 | 73 | # apply MVNW_REPOURL and calculate MAVEN_HOME 74 | # maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ 75 | if ($env:MVNW_REPOURL) { 76 | $MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" } 77 | $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')" 78 | } 79 | $distributionUrlName = $distributionUrl -replace '^.*/','' 80 | $distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' 81 | $MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain" 82 | $MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' 83 | $MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" 84 | 85 | if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { 86 | Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" 87 | Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" 88 | exit $? 89 | } 90 | 91 | if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { 92 | Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" 93 | } 94 | 95 | # prepare tmp dir 96 | $TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile 97 | $TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" 98 | $TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null 99 | trap { 100 | if ($TMP_DOWNLOAD_DIR.Exists) { 101 | try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } 102 | catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } 103 | } 104 | } 105 | 106 | New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null 107 | 108 | # Download and Install Apache Maven 109 | Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." 110 | Write-Verbose "Downloading from: $distributionUrl" 111 | Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" 112 | 113 | $webclient = New-Object System.Net.WebClient 114 | if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { 115 | $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) 116 | } 117 | [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 118 | $webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null 119 | 120 | # If specified, validate the SHA-256 sum of the Maven distribution zip file 121 | $distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum 122 | if ($distributionSha256Sum) { 123 | if ($USE_MVND) { 124 | Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." 125 | } 126 | Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash 127 | if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { 128 | Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." 129 | } 130 | } 131 | 132 | # unzip and move 133 | Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null 134 | Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null 135 | try { 136 | Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null 137 | } catch { 138 | if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { 139 | Write-Error "fail to move MAVEN_HOME" 140 | } 141 | } finally { 142 | try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } 143 | catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } 144 | } 145 | 146 | Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" 147 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | org.springframework.boot 8 | spring-boot-starter-parent 9 | 3.3.0 10 | 11 | 12 | com.marcusmonteirodesouza 13 | realworld 14 | 0.0.1-SNAPSHOT 15 | realworld 16 | Mother of All Demo Apps 17 | 18 | 21 19 | 20 | 21 | 22 | org.springframework.boot 23 | spring-boot-starter-data-jpa 24 | 25 | 26 | org.springframework.boot 27 | spring-boot-starter-security 28 | 29 | 30 | org.springframework.boot 31 | spring-boot-starter-web 32 | 33 | 34 | 36 | 37 | org.springframework.boot 38 | spring-boot-starter-oauth2-resource-server 39 | 3.3.0 40 | 41 | 42 | 43 | 44 | commons-validator 45 | commons-validator 46 | 1.9.0 47 | 48 | 49 | 50 | 51 | com.google.guava 52 | guava 53 | 33.2.1-jre 54 | 55 | 56 | 57 | 58 | com.github.slugify 59 | slugify 60 | 3.0.7 61 | 62 | 63 | 64 | 65 | org.postgresql 66 | postgresql 67 | 42.7.3 68 | 69 | 70 | 71 | 72 | org.keycloak 73 | keycloak-admin-client 74 | 24.0.5 75 | 76 | 77 | 78 | me.paulschwarz 79 | spring-dotenv 80 | 4.0.0 81 | 82 | 83 | 84 | org.springframework.boot 85 | spring-boot-devtools 86 | runtime 87 | true 88 | 89 | 90 | org.springframework.boot 91 | spring-boot-starter-test 92 | test 93 | 94 | 95 | org.springframework.security 96 | spring-security-test 97 | test 98 | 99 | 100 | 101 | 102 | 103 | 104 | org.springframework.boot 105 | spring-boot-maven-plugin 106 | 107 | 108 | com.diffplug.spotless 109 | spotless-maven-plugin 110 | 2.43.0 111 | 112 | 113 | 114 | 115 | 116 | 117 | .gitattributes 118 | .gitignore 119 | 120 | 121 | 122 | 123 | 124 | true 125 | 4 126 | 127 | 128 | 129 | 130 | 131 | 133 | 134 | 135 | 136 | 1.22.0 137 | 138 | true 139 | false 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | -------------------------------------------------------------------------------- /src/main/java/com/marcusmonteirodesouza/realworld/RealworldApplication.java: -------------------------------------------------------------------------------- 1 | package com.marcusmonteirodesouza.realworld; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | 6 | @SpringBootApplication 7 | public class RealworldApplication { 8 | 9 | public static void main(String[] args) { 10 | SpringApplication.run(RealworldApplication.class, args); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/com/marcusmonteirodesouza/realworld/api/articles/controllers/ArticlesController.java: -------------------------------------------------------------------------------- 1 | package com.marcusmonteirodesouza.realworld.api.articles.controllers; 2 | 3 | import com.marcusmonteirodesouza.realworld.api.articles.controllers.dto.AddCommentToArticleRequest; 4 | import com.marcusmonteirodesouza.realworld.api.articles.controllers.dto.ArticleResponse; 5 | import com.marcusmonteirodesouza.realworld.api.articles.controllers.dto.ArticleResponse.ArticleResponseArticle; 6 | import com.marcusmonteirodesouza.realworld.api.articles.controllers.dto.CommentResponse; 7 | import com.marcusmonteirodesouza.realworld.api.articles.controllers.dto.CommentResponse.CommentResponseComment; 8 | import com.marcusmonteirodesouza.realworld.api.articles.controllers.dto.CreateArticleRequest; 9 | import com.marcusmonteirodesouza.realworld.api.articles.controllers.dto.MultipleArticlesResponse; 10 | import com.marcusmonteirodesouza.realworld.api.articles.controllers.dto.MultipleCommentsResponse; 11 | import com.marcusmonteirodesouza.realworld.api.articles.controllers.dto.UpdateArticleRequest; 12 | import com.marcusmonteirodesouza.realworld.api.articles.services.ArticlesService; 13 | import com.marcusmonteirodesouza.realworld.api.articles.services.parameterobjects.ArticleCreate; 14 | import com.marcusmonteirodesouza.realworld.api.articles.services.parameterobjects.ArticleUpdate; 15 | import com.marcusmonteirodesouza.realworld.api.articles.services.parameterobjects.ArticlesList; 16 | import com.marcusmonteirodesouza.realworld.api.authentication.IAuthenticationFacade; 17 | import com.marcusmonteirodesouza.realworld.api.exceptions.AlreadyExistsException; 18 | import com.marcusmonteirodesouza.realworld.api.profiles.models.Profile; 19 | import com.marcusmonteirodesouza.realworld.api.profiles.services.ProfilesService; 20 | import com.marcusmonteirodesouza.realworld.api.users.services.users.UsersService; 21 | import jakarta.transaction.Transactional; 22 | import jakarta.ws.rs.ForbiddenException; 23 | import jakarta.ws.rs.NotFoundException; 24 | import java.util.Arrays; 25 | import java.util.Collection; 26 | import java.util.Optional; 27 | import java.util.stream.Collectors; 28 | import org.springframework.http.HttpStatus; 29 | import org.springframework.web.bind.annotation.DeleteMapping; 30 | import org.springframework.web.bind.annotation.GetMapping; 31 | import org.springframework.web.bind.annotation.PathVariable; 32 | import org.springframework.web.bind.annotation.PostMapping; 33 | import org.springframework.web.bind.annotation.PutMapping; 34 | import org.springframework.web.bind.annotation.RequestBody; 35 | import org.springframework.web.bind.annotation.RequestMapping; 36 | import org.springframework.web.bind.annotation.RequestParam; 37 | import org.springframework.web.bind.annotation.ResponseStatus; 38 | import org.springframework.web.bind.annotation.RestController; 39 | 40 | @RestController 41 | @RequestMapping(path = "/articles") 42 | public class ArticlesController { 43 | private final IAuthenticationFacade authenticationFacade; 44 | private final ArticlesService articlesService; 45 | private final UsersService usersService; 46 | private final ProfilesService profilesService; 47 | 48 | public ArticlesController( 49 | IAuthenticationFacade authenticationFacade, 50 | ArticlesService articlesService, 51 | UsersService usersService, 52 | ProfilesService profilesService) { 53 | this.authenticationFacade = authenticationFacade; 54 | this.articlesService = articlesService; 55 | this.usersService = usersService; 56 | this.profilesService = profilesService; 57 | } 58 | 59 | @PostMapping() 60 | @ResponseStatus(HttpStatus.CREATED) 61 | @Transactional 62 | public ArticleResponse createArticle(@RequestBody CreateArticleRequest request) 63 | throws AlreadyExistsException { 64 | var maybeUserId = Optional.of(authenticationFacade.getAuthentication().getName()); 65 | 66 | var article = 67 | articlesService.createArticle( 68 | new ArticleCreate( 69 | maybeUserId.get(), 70 | request.article.title, 71 | request.article.description, 72 | request.article.body, 73 | Optional.ofNullable(request.article.tagList))); 74 | 75 | var authorProfile = profilesService.getProfile(article.getAuthorId(), maybeUserId); 76 | 77 | return new ArticleResponse(maybeUserId, article, authorProfile); 78 | } 79 | 80 | @PostMapping("/{slug}/favorite") 81 | @Transactional 82 | public ArticleResponse favoriteArticle(@PathVariable String slug) { 83 | var maybeUserId = Optional.of(authenticationFacade.getAuthentication().getName()); 84 | 85 | var article = articlesService.getArticleBySlug(slug).orElse(null); 86 | 87 | if (article == null) { 88 | throw new NotFoundException("Article with slug '" + slug + "' not found"); 89 | } 90 | 91 | article = articlesService.favoriteArticle(maybeUserId.get(), article.getId()); 92 | 93 | var authorProfile = profilesService.getProfile(article.getAuthorId(), maybeUserId); 94 | 95 | return new ArticleResponse(maybeUserId, article, authorProfile); 96 | } 97 | 98 | @PostMapping("/{slug}/comments") 99 | @Transactional 100 | public CommentResponse addCommentToArticle( 101 | @PathVariable String slug, @RequestBody AddCommentToArticleRequest request) { 102 | var maybeUserId = Optional.of(authenticationFacade.getAuthentication().getName()); 103 | 104 | var article = articlesService.getArticleBySlug(slug).orElse(null); 105 | 106 | if (article == null) { 107 | throw new NotFoundException("Article with slug '" + slug + "' not found"); 108 | } 109 | 110 | var comment = 111 | articlesService.addCommentToArticle( 112 | article.getId(), maybeUserId.get(), request.comment.body); 113 | 114 | var authorProfile = profilesService.getProfile(comment.getAuthorId(), maybeUserId); 115 | 116 | return new CommentResponse(maybeUserId, comment, authorProfile); 117 | } 118 | 119 | @GetMapping() 120 | public MultipleArticlesResponse listArticles( 121 | @RequestParam(required = false) String tag, 122 | @RequestParam(required = false) String author, 123 | @RequestParam(required = false) String favorited, 124 | @RequestParam(defaultValue = "20") Integer limit, 125 | @RequestParam(defaultValue = "0") Integer offset) { 126 | var maybeUserId = Optional.ofNullable(authenticationFacade.getAuthentication().getName()); 127 | 128 | Optional> authorIds = 129 | author == null ? Optional.empty() : Optional.of(Arrays.asList(author)); 130 | 131 | Optional favoritedByUserId = Optional.empty(); 132 | if (favorited != null) { 133 | var favoritedByUser = usersService.getUserByUsername(favorited).orElse(null); 134 | 135 | if (favoritedByUser == null) { 136 | throw new NotFoundException("User with username '" + favorited + "' not found"); 137 | } 138 | 139 | favoritedByUserId = Optional.of(favoritedByUser.getId()); 140 | } 141 | 142 | var articles = 143 | articlesService.listArticles( 144 | new ArticlesList( 145 | Optional.ofNullable(tag), 146 | authorIds, 147 | favoritedByUserId, 148 | Optional.of(limit), 149 | Optional.of(offset))); 150 | 151 | var articleResponses = 152 | articles.stream() 153 | .map( 154 | article -> { 155 | var authorProfile = 156 | profilesService.getProfile( 157 | article.getAuthorId(), maybeUserId); 158 | 159 | return new ArticleResponseArticle( 160 | maybeUserId, article, authorProfile); 161 | }) 162 | .collect(Collectors.toList()); 163 | 164 | return new MultipleArticlesResponse(articleResponses); 165 | } 166 | 167 | @GetMapping("/feed") 168 | public MultipleArticlesResponse feedArticles( 169 | @RequestParam(defaultValue = "20") Integer limit, 170 | @RequestParam(defaultValue = "0") Integer offset) { 171 | var maybeUserId = Optional.of(authenticationFacade.getAuthentication().getName()); 172 | 173 | var followedUserProfilesMap = 174 | profilesService.listProfilesFollowedByUserId(maybeUserId.get()).stream() 175 | .collect(Collectors.toMap(Profile::getUserId, profile -> profile)); 176 | 177 | var articles = 178 | articlesService.listArticles( 179 | new ArticlesList( 180 | Optional.empty(), 181 | Optional.of( 182 | followedUserProfilesMap.keySet().stream() 183 | .collect(Collectors.toList())), 184 | Optional.empty(), 185 | Optional.of(limit), 186 | Optional.of(offset))); 187 | 188 | var articleResponseArticles = 189 | articles.stream() 190 | .map( 191 | article -> { 192 | var authorProfile = 193 | followedUserProfilesMap.get(article.getAuthorId()); 194 | 195 | return new ArticleResponseArticle( 196 | maybeUserId, article, authorProfile); 197 | }) 198 | .collect(Collectors.toList()); 199 | 200 | return new MultipleArticlesResponse(articleResponseArticles); 201 | } 202 | 203 | @GetMapping("/{slug}") 204 | public ArticleResponse getArticle(@PathVariable String slug) { 205 | var maybeUserId = Optional.ofNullable(authenticationFacade.getAuthentication().getName()); 206 | 207 | var article = articlesService.getArticleBySlug(slug).orElse(null); 208 | 209 | if (article == null) { 210 | throw new NotFoundException("Article with slug '" + slug + "' not found"); 211 | } 212 | 213 | var authorProfile = profilesService.getProfile(article.getAuthorId(), maybeUserId); 214 | 215 | return new ArticleResponse(maybeUserId, article, authorProfile); 216 | } 217 | 218 | @GetMapping("/{slug}/comments") 219 | @Transactional 220 | public MultipleCommentsResponse getArticleComments(@PathVariable String slug) { 221 | var maybeUserId = Optional.ofNullable(authenticationFacade.getAuthentication().getName()); 222 | 223 | var article = articlesService.getArticleBySlug(slug).orElse(null); 224 | 225 | if (article == null) { 226 | throw new NotFoundException("Article with slug '" + slug + "' not found"); 227 | } 228 | 229 | var comments = articlesService.listCommentsByArticleId(article.getId()); 230 | 231 | var commentResponseComments = 232 | comments.stream() 233 | .map( 234 | comment -> { 235 | var authorProfile = 236 | profilesService.getProfile( 237 | comment.getAuthorId(), maybeUserId); 238 | 239 | return new CommentResponseComment( 240 | maybeUserId, comment, authorProfile); 241 | }) 242 | .collect(Collectors.toList()); 243 | 244 | return new MultipleCommentsResponse(commentResponseComments); 245 | } 246 | 247 | @PutMapping("/{slug}") 248 | @Transactional 249 | public ArticleResponse updateArticle( 250 | @PathVariable String slug, @RequestBody UpdateArticleRequest request) { 251 | var maybeUserId = Optional.of(authenticationFacade.getAuthentication().getName()); 252 | 253 | var article = articlesService.getArticleBySlug(slug).orElse(null); 254 | 255 | if (article == null) { 256 | throw new NotFoundException("Article with slug '" + slug + "' not found"); 257 | } 258 | 259 | if (!article.getAuthorId().equals(maybeUserId.get())) { 260 | throw new ForbiddenException( 261 | "User '" 262 | + maybeUserId.get() 263 | + "' can't update Article with slug '" 264 | + slug 265 | + '"'); 266 | } 267 | 268 | article = 269 | articlesService.updateArticle( 270 | article.getId(), 271 | new ArticleUpdate( 272 | Optional.ofNullable(request.article.title), 273 | Optional.ofNullable(request.article.description), 274 | Optional.ofNullable(request.article.body))); 275 | 276 | var authorProfile = profilesService.getProfile(article.getAuthorId(), maybeUserId); 277 | 278 | return new ArticleResponse(maybeUserId, article, authorProfile); 279 | } 280 | 281 | @DeleteMapping("/{slug}") 282 | @ResponseStatus(HttpStatus.NO_CONTENT) 283 | @Transactional 284 | public void deleteArticle(@PathVariable String slug) { 285 | var maybeUserId = Optional.of(authenticationFacade.getAuthentication().getName()); 286 | 287 | var article = articlesService.getArticleBySlug(slug).orElse(null); 288 | 289 | if (article == null) { 290 | throw new NotFoundException("Article with slug '" + slug + "' not found"); 291 | } 292 | 293 | if (!article.getAuthorId().equals(maybeUserId.get())) { 294 | throw new ForbiddenException( 295 | "User '" 296 | + maybeUserId.get() 297 | + "' cannot delete Article with slug '" 298 | + slug 299 | + "'"); 300 | } 301 | 302 | articlesService.deleteArticleById(article.getId()); 303 | } 304 | 305 | @DeleteMapping("/{slug}/favorite") 306 | @Transactional 307 | public ArticleResponse unfavoriteArticle(@PathVariable String slug) { 308 | var maybeUserId = Optional.of(authenticationFacade.getAuthentication().getName()); 309 | 310 | var article = articlesService.getArticleBySlug(slug).orElse(null); 311 | 312 | if (article == null) { 313 | throw new NotFoundException("Article with slug '" + slug + "' not found"); 314 | } 315 | 316 | article = articlesService.unfavoriteArticle(maybeUserId.get(), article.getId()); 317 | 318 | var authorProfile = profilesService.getProfile(article.getAuthorId(), maybeUserId); 319 | 320 | return new ArticleResponse(maybeUserId, article, authorProfile); 321 | } 322 | 323 | @DeleteMapping("/{slug}/comments/{commentId}") 324 | @ResponseStatus(HttpStatus.NO_CONTENT) 325 | @Transactional 326 | public void deleteArticle(@PathVariable String slug, @PathVariable String commentId) { 327 | var maybeUserId = Optional.of(authenticationFacade.getAuthentication().getName()); 328 | 329 | var comment = articlesService.getCommentById(commentId).orElse(null); 330 | 331 | if (comment == null) { 332 | throw new NotFoundException("Comment '" + commentId + "' not found"); 333 | } 334 | 335 | if (!comment.getAuthorId().equals(maybeUserId.get())) { 336 | throw new ForbiddenException( 337 | "User '" + maybeUserId.get() + "' cannot delete Comment '" + commentId + "'"); 338 | } 339 | 340 | articlesService.deleteCommentById(commentId); 341 | } 342 | } 343 | -------------------------------------------------------------------------------- /src/main/java/com/marcusmonteirodesouza/realworld/api/articles/controllers/TagsController.java: -------------------------------------------------------------------------------- 1 | package com.marcusmonteirodesouza.realworld.api.articles.controllers; 2 | 3 | import com.marcusmonteirodesouza.realworld.api.articles.controllers.dto.ListOfTagsResponse; 4 | import com.marcusmonteirodesouza.realworld.api.articles.services.ArticlesService; 5 | import java.util.Collections; 6 | import java.util.stream.Collectors; 7 | import org.springframework.web.bind.annotation.GetMapping; 8 | import org.springframework.web.bind.annotation.RequestMapping; 9 | import org.springframework.web.bind.annotation.RestController; 10 | 11 | @RestController 12 | @RequestMapping(path = "/tags") 13 | public class TagsController { 14 | private final ArticlesService articlesService; 15 | 16 | public TagsController(ArticlesService articlesService) { 17 | this.articlesService = articlesService; 18 | } 19 | 20 | @GetMapping() 21 | public ListOfTagsResponse getTags() { 22 | var tags = articlesService.listTags(); 23 | 24 | var listOfTags = 25 | tags.stream().map(tag -> tag.getValue()).sorted().collect(Collectors.toList()); 26 | 27 | Collections.sort(listOfTags); 28 | 29 | return new ListOfTagsResponse(listOfTags); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/java/com/marcusmonteirodesouza/realworld/api/articles/controllers/dto/AddCommentToArticleRequest.java: -------------------------------------------------------------------------------- 1 | package com.marcusmonteirodesouza.realworld.api.articles.controllers.dto; 2 | 3 | public class AddCommentToArticleRequest { 4 | public AddCommentToArticleRequestComment comment; 5 | 6 | public AddCommentToArticleRequest() {} 7 | 8 | public AddCommentToArticleRequest(AddCommentToArticleRequestComment comment) { 9 | this.comment = comment; 10 | } 11 | 12 | public static final class AddCommentToArticleRequestComment { 13 | public String body; 14 | 15 | public AddCommentToArticleRequestComment() {} 16 | 17 | public AddCommentToArticleRequestComment(String body) { 18 | this.body = body; 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/com/marcusmonteirodesouza/realworld/api/articles/controllers/dto/ArticleResponse.java: -------------------------------------------------------------------------------- 1 | package com.marcusmonteirodesouza.realworld.api.articles.controllers.dto; 2 | 3 | import com.marcusmonteirodesouza.realworld.api.articles.models.Article; 4 | import com.marcusmonteirodesouza.realworld.api.profiles.models.Profile; 5 | import java.util.Collection; 6 | import java.util.Date; 7 | import java.util.Optional; 8 | 9 | public class ArticleResponse { 10 | private final ArticleResponseArticle article; 11 | 12 | public ArticleResponse(Optional maybeUserId, Article article, Profile authorProfile) { 13 | this.article = new ArticleResponseArticle(maybeUserId, article, authorProfile); 14 | } 15 | 16 | public ArticleResponseArticle getArticle() { 17 | return article; 18 | } 19 | 20 | public static final class ArticleResponseArticle { 21 | private final String slug; 22 | private final String title; 23 | private final String description; 24 | private final String body; 25 | private final Collection tagList; 26 | private final Date createdAt; 27 | private final Date updatedAt; 28 | private final Boolean favorited; 29 | private final Integer favoritesCount; 30 | private final ArticleResponseAuthor author; 31 | 32 | public ArticleResponseArticle( 33 | Optional maybeUserId, Article article, Profile authorProfile) { 34 | this.slug = article.getSlug(); 35 | this.title = article.getTitle(); 36 | this.description = article.getDescription(); 37 | this.body = article.getBody(); 38 | this.tagList = article.getTagList().stream().map(tag -> tag.getValue()).toList(); 39 | this.createdAt = article.getCreatedAt(); 40 | this.updatedAt = article.getUpdatedAt(); 41 | this.favorited = 42 | maybeUserId.isPresent() 43 | ? article.getFavorites().stream() 44 | .filter( 45 | favorite -> 46 | favorite.getUserId().equals(maybeUserId.get())) 47 | .findFirst() 48 | .isPresent() 49 | : false; 50 | this.favoritesCount = article.getFavorites().size(); 51 | this.author = 52 | new ArticleResponseAuthor( 53 | authorProfile.getUsername(), 54 | authorProfile.getBio().orElse(null), 55 | authorProfile.getImage().orElse(null), 56 | authorProfile.getFollowing()); 57 | } 58 | 59 | public String getSlug() { 60 | return slug; 61 | } 62 | 63 | public String getTitle() { 64 | return title; 65 | } 66 | 67 | public String getDescription() { 68 | return description; 69 | } 70 | 71 | public String getBody() { 72 | return body; 73 | } 74 | 75 | public Collection getTagList() { 76 | return tagList; 77 | } 78 | 79 | public Date getCreatedAt() { 80 | return createdAt; 81 | } 82 | 83 | public Date getUpdatedAt() { 84 | return updatedAt; 85 | } 86 | 87 | public Boolean getFavorited() { 88 | return favorited; 89 | } 90 | 91 | public Integer getFavoritesCount() { 92 | return favoritesCount; 93 | } 94 | 95 | public ArticleResponseAuthor getAuthor() { 96 | return author; 97 | } 98 | } 99 | 100 | public static final class ArticleResponseAuthor { 101 | private final String username; 102 | private final String bio; 103 | private final String image; 104 | private final Boolean following; 105 | 106 | public ArticleResponseAuthor(String username, String bio, String image, Boolean following) { 107 | this.username = username; 108 | this.bio = bio; 109 | this.image = image; 110 | this.following = following; 111 | } 112 | 113 | public String getUsername() { 114 | return username; 115 | } 116 | 117 | public String getBio() { 118 | return bio; 119 | } 120 | 121 | public String getImage() { 122 | return image; 123 | } 124 | 125 | public Boolean getFollowing() { 126 | return following; 127 | } 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/main/java/com/marcusmonteirodesouza/realworld/api/articles/controllers/dto/CommentResponse.java: -------------------------------------------------------------------------------- 1 | package com.marcusmonteirodesouza.realworld.api.articles.controllers.dto; 2 | 3 | import com.marcusmonteirodesouza.realworld.api.articles.models.Comment; 4 | import com.marcusmonteirodesouza.realworld.api.profiles.models.Profile; 5 | import java.util.Date; 6 | import java.util.Optional; 7 | 8 | public class CommentResponse { 9 | private final CommentResponseComment comment; 10 | 11 | public CommentResponse(Optional maybeUserId, Comment comment, Profile authorProfile) { 12 | this.comment = new CommentResponseComment(maybeUserId, comment, authorProfile); 13 | } 14 | 15 | public CommentResponseComment getComment() { 16 | return comment; 17 | } 18 | 19 | public static final class CommentResponseComment { 20 | private final String id; 21 | private final Date createdAt; 22 | private final Date updatedAt; 23 | private final String body; 24 | private final CommentResponseAuthor author; 25 | 26 | public CommentResponseComment( 27 | Optional maybeUserId, Comment comment, Profile authorProfile) { 28 | this.id = comment.getId(); 29 | this.body = comment.getBody(); 30 | this.createdAt = comment.getCreatedAt(); 31 | this.updatedAt = comment.getUpdatedAt(); 32 | this.author = 33 | new CommentResponseAuthor( 34 | authorProfile.getUsername(), 35 | authorProfile.getBio().orElse(null), 36 | authorProfile.getImage().orElse(null), 37 | authorProfile.getFollowing()); 38 | } 39 | 40 | public String getId() { 41 | return id; 42 | } 43 | 44 | public String getBody() { 45 | return body; 46 | } 47 | 48 | public Date getCreatedAt() { 49 | return createdAt; 50 | } 51 | 52 | public Date getUpdatedAt() { 53 | return updatedAt; 54 | } 55 | 56 | public CommentResponseAuthor getAuthor() { 57 | return author; 58 | } 59 | } 60 | 61 | public static final class CommentResponseAuthor { 62 | private final String username; 63 | private final String bio; 64 | private final String image; 65 | private final Boolean following; 66 | 67 | public CommentResponseAuthor(String username, String bio, String image, Boolean following) { 68 | this.username = username; 69 | this.bio = bio; 70 | this.image = image; 71 | this.following = following; 72 | } 73 | 74 | public String getUsername() { 75 | return username; 76 | } 77 | 78 | public String getBio() { 79 | return bio; 80 | } 81 | 82 | public String getImage() { 83 | return image; 84 | } 85 | 86 | public Boolean getFollowing() { 87 | return following; 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/main/java/com/marcusmonteirodesouza/realworld/api/articles/controllers/dto/CreateArticleRequest.java: -------------------------------------------------------------------------------- 1 | package com.marcusmonteirodesouza.realworld.api.articles.controllers.dto; 2 | 3 | import java.util.Collection; 4 | 5 | public class CreateArticleRequest { 6 | public CreateArticleRequestArticle article; 7 | 8 | public CreateArticleRequest() {} 9 | 10 | public CreateArticleRequest(CreateArticleRequestArticle article) { 11 | this.article = article; 12 | } 13 | 14 | public static final class CreateArticleRequestArticle { 15 | public String title; 16 | public String description; 17 | public String body; 18 | public Collection tagList; 19 | 20 | public CreateArticleRequestArticle() {} 21 | 22 | public CreateArticleRequestArticle( 23 | String title, String description, String body, Collection tagList) { 24 | this.title = title; 25 | this.description = description; 26 | this.body = body; 27 | this.tagList = tagList; 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/com/marcusmonteirodesouza/realworld/api/articles/controllers/dto/ListOfTagsResponse.java: -------------------------------------------------------------------------------- 1 | package com.marcusmonteirodesouza.realworld.api.articles.controllers.dto; 2 | 3 | import java.util.List; 4 | 5 | public class ListOfTagsResponse { 6 | private final List tags; 7 | 8 | public ListOfTagsResponse(List tags) { 9 | this.tags = tags; 10 | } 11 | 12 | public List getTags() { 13 | return tags; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/com/marcusmonteirodesouza/realworld/api/articles/controllers/dto/MultipleArticlesResponse.java: -------------------------------------------------------------------------------- 1 | package com.marcusmonteirodesouza.realworld.api.articles.controllers.dto; 2 | 3 | import com.marcusmonteirodesouza.realworld.api.articles.controllers.dto.ArticleResponse.ArticleResponseArticle; 4 | import java.util.List; 5 | 6 | public class MultipleArticlesResponse { 7 | private final List articles; 8 | private final Integer articlesCount; 9 | 10 | public MultipleArticlesResponse(List articles) { 11 | this.articles = articles; 12 | this.articlesCount = articles.size(); 13 | } 14 | 15 | public List getArticles() { 16 | return articles; 17 | } 18 | 19 | public Integer getArticlesCount() { 20 | return articlesCount; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/java/com/marcusmonteirodesouza/realworld/api/articles/controllers/dto/MultipleCommentsResponse.java: -------------------------------------------------------------------------------- 1 | package com.marcusmonteirodesouza.realworld.api.articles.controllers.dto; 2 | 3 | import com.marcusmonteirodesouza.realworld.api.articles.controllers.dto.CommentResponse.CommentResponseComment; 4 | import java.util.List; 5 | 6 | public class MultipleCommentsResponse { 7 | private final List comments; 8 | 9 | public MultipleCommentsResponse(List comments) { 10 | this.comments = comments; 11 | } 12 | 13 | public List getComments() { 14 | return comments; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/com/marcusmonteirodesouza/realworld/api/articles/controllers/dto/UpdateArticleRequest.java: -------------------------------------------------------------------------------- 1 | package com.marcusmonteirodesouza.realworld.api.articles.controllers.dto; 2 | 3 | public class UpdateArticleRequest { 4 | public UpdateArticleRequestArticle article; 5 | 6 | public UpdateArticleRequest() {} 7 | 8 | public UpdateArticleRequest(UpdateArticleRequestArticle article) { 9 | this.article = article; 10 | } 11 | 12 | public static final class UpdateArticleRequestArticle { 13 | public String title; 14 | public String description; 15 | public String body; 16 | 17 | public UpdateArticleRequestArticle() {} 18 | 19 | public UpdateArticleRequestArticle(String title, String description, String body) { 20 | this.title = title; 21 | this.description = description; 22 | this.body = body; 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/com/marcusmonteirodesouza/realworld/api/articles/models/Article.java: -------------------------------------------------------------------------------- 1 | package com.marcusmonteirodesouza.realworld.api.articles.models; 2 | 3 | import jakarta.persistence.CascadeType; 4 | import jakarta.persistence.Column; 5 | import jakarta.persistence.Entity; 6 | import jakarta.persistence.GeneratedValue; 7 | import jakarta.persistence.GenerationType; 8 | import jakarta.persistence.Id; 9 | import jakarta.persistence.ManyToMany; 10 | import jakarta.persistence.OneToMany; 11 | import jakarta.validation.constraints.NotBlank; 12 | import java.util.Date; 13 | import java.util.HashSet; 14 | import java.util.Set; 15 | import org.hibernate.annotations.CreationTimestamp; 16 | import org.hibernate.annotations.UpdateTimestamp; 17 | 18 | @Entity 19 | public class Article { 20 | @Id 21 | @GeneratedValue(strategy = GenerationType.UUID) 22 | private String id; 23 | 24 | @NotBlank private String authorId; 25 | 26 | @Column(unique = true) 27 | @NotBlank 28 | private String slug; 29 | 30 | @NotBlank private String title; 31 | 32 | @NotBlank private String description; 33 | 34 | @NotBlank private String body; 35 | 36 | @ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE}) 37 | private Set tagList = new HashSet(); 38 | 39 | @OneToMany( 40 | mappedBy = "article", 41 | cascade = {CascadeType.ALL}, 42 | orphanRemoval = true) 43 | private Set favorites = new HashSet(); 44 | 45 | @OneToMany( 46 | mappedBy = "article", 47 | cascade = {CascadeType.ALL}, 48 | orphanRemoval = true) 49 | private Set comments = new HashSet(); 50 | 51 | @CreationTimestamp private Date createdAt; 52 | 53 | @UpdateTimestamp private Date updatedAt; 54 | 55 | public Article() {} 56 | 57 | public String getId() { 58 | return id; 59 | } 60 | 61 | public String getAuthorId() { 62 | return authorId; 63 | } 64 | 65 | public void setAuthorId(String authorId) { 66 | this.authorId = authorId; 67 | } 68 | 69 | public String getSlug() { 70 | return slug; 71 | } 72 | 73 | public void setSlug(String slug) { 74 | this.slug = slug; 75 | } 76 | 77 | public String getTitle() { 78 | return title; 79 | } 80 | 81 | public void setTitle(String title) { 82 | this.title = title; 83 | } 84 | 85 | public String getDescription() { 86 | return description; 87 | } 88 | 89 | public void setDescription(String description) { 90 | this.description = description; 91 | } 92 | 93 | public String getBody() { 94 | return body; 95 | } 96 | 97 | public void setBody(String body) { 98 | this.body = body; 99 | } 100 | 101 | public Set getTagList() { 102 | return tagList; 103 | } 104 | 105 | public void setTagList(Set tagList) { 106 | tagList.forEach(tag -> addTag(tag)); 107 | } 108 | 109 | public void addTag(Tag tag) { 110 | this.tagList.add(tag); 111 | tag.getArticles().add(this); 112 | } 113 | 114 | public void removeTag(Tag tag) { 115 | this.tagList.remove(tag); 116 | tag.getArticles().remove(this); 117 | } 118 | 119 | public Set getFavorites() { 120 | return favorites; 121 | } 122 | 123 | public void addFavorite(Favorite favorite) { 124 | this.favorites.add(favorite); 125 | favorite.setArticle(this); 126 | } 127 | 128 | public void removeFavorite(Favorite favorite) { 129 | this.favorites.remove(favorite); 130 | favorite.setArticle(null); 131 | } 132 | 133 | public Set getComments() { 134 | return comments; 135 | } 136 | 137 | public void addComment(Comment comment) { 138 | this.comments.add(comment); 139 | comment.setArticle(this); 140 | } 141 | 142 | public void removeComment(Comment comment) { 143 | this.comments.remove(comment); 144 | comment.setArticle(null); 145 | } 146 | 147 | public Date getCreatedAt() { 148 | return createdAt; 149 | } 150 | 151 | public void setCreatedAt(Date createdAt) { 152 | this.createdAt = createdAt; 153 | } 154 | 155 | public Date getUpdatedAt() { 156 | return updatedAt; 157 | } 158 | 159 | public void setUpdatedAt(Date updatedAt) { 160 | this.updatedAt = updatedAt; 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/main/java/com/marcusmonteirodesouza/realworld/api/articles/models/Comment.java: -------------------------------------------------------------------------------- 1 | package com.marcusmonteirodesouza.realworld.api.articles.models; 2 | 3 | import jakarta.persistence.Entity; 4 | import jakarta.persistence.FetchType; 5 | import jakarta.persistence.GeneratedValue; 6 | import jakarta.persistence.GenerationType; 7 | import jakarta.persistence.Id; 8 | import jakarta.persistence.ManyToOne; 9 | import jakarta.validation.constraints.NotBlank; 10 | import java.util.Date; 11 | import org.hibernate.annotations.CreationTimestamp; 12 | 13 | @Entity 14 | public class Comment { 15 | @Id 16 | @GeneratedValue(strategy = GenerationType.UUID) 17 | private String id; 18 | 19 | @NotBlank private String authorId; 20 | 21 | @NotBlank private String body; 22 | 23 | @ManyToOne(fetch = FetchType.EAGER) 24 | private Article article; 25 | 26 | @CreationTimestamp private Date createdAt; 27 | 28 | @CreationTimestamp private Date updatedAt; 29 | 30 | public Comment() {} 31 | 32 | public String getId() { 33 | return id; 34 | } 35 | 36 | public String getAuthorId() { 37 | return authorId; 38 | } 39 | 40 | public void setAuthorId(String authorId) { 41 | this.authorId = authorId; 42 | } 43 | 44 | public String getBody() { 45 | return body; 46 | } 47 | 48 | public void setBody(String userId) { 49 | this.body = userId; 50 | } 51 | 52 | public Article getArticle() { 53 | return article; 54 | } 55 | 56 | public void setArticle(Article article) { 57 | this.article = article; 58 | } 59 | 60 | public Date getCreatedAt() { 61 | return createdAt; 62 | } 63 | 64 | public void setCreatedAt(Date createdAt) { 65 | this.createdAt = createdAt; 66 | } 67 | 68 | public Date getUpdatedAt() { 69 | return createdAt; 70 | } 71 | 72 | public void setUpdatedAt(Date updatedAt) { 73 | this.updatedAt = updatedAt; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/main/java/com/marcusmonteirodesouza/realworld/api/articles/models/Favorite.java: -------------------------------------------------------------------------------- 1 | package com.marcusmonteirodesouza.realworld.api.articles.models; 2 | 3 | import jakarta.persistence.Entity; 4 | import jakarta.persistence.FetchType; 5 | import jakarta.persistence.GeneratedValue; 6 | import jakarta.persistence.GenerationType; 7 | import jakarta.persistence.Id; 8 | import jakarta.persistence.ManyToOne; 9 | import jakarta.validation.constraints.NotBlank; 10 | import java.sql.Timestamp; 11 | import org.hibernate.annotations.CreationTimestamp; 12 | 13 | @Entity 14 | public class Favorite { 15 | @Id 16 | @GeneratedValue(strategy = GenerationType.UUID) 17 | private String id; 18 | 19 | @NotBlank private String userId; 20 | 21 | @ManyToOne(fetch = FetchType.EAGER) 22 | private Article article; 23 | 24 | @CreationTimestamp private Timestamp createdAt; 25 | 26 | public Favorite() {} 27 | 28 | public String getId() { 29 | return id; 30 | } 31 | 32 | public String getUserId() { 33 | return userId; 34 | } 35 | 36 | public void setUserId(String userId) { 37 | this.userId = userId; 38 | } 39 | 40 | public Article getArticle() { 41 | return article; 42 | } 43 | 44 | public void setArticle(Article article) { 45 | this.article = article; 46 | } 47 | 48 | public Timestamp getCreatedAt() { 49 | return createdAt; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/main/java/com/marcusmonteirodesouza/realworld/api/articles/models/Tag.java: -------------------------------------------------------------------------------- 1 | package com.marcusmonteirodesouza.realworld.api.articles.models; 2 | 3 | import jakarta.persistence.Column; 4 | import jakarta.persistence.Entity; 5 | import jakarta.persistence.FetchType; 6 | import jakarta.persistence.GeneratedValue; 7 | import jakarta.persistence.GenerationType; 8 | import jakarta.persistence.Id; 9 | import jakarta.persistence.ManyToMany; 10 | import jakarta.validation.constraints.NotBlank; 11 | import java.util.Collection; 12 | import java.util.HashSet; 13 | import java.util.Set; 14 | 15 | @Entity 16 | public class Tag { 17 | @Id 18 | @GeneratedValue(strategy = GenerationType.UUID) 19 | private String id; 20 | 21 | @Column(unique = true) 22 | @NotBlank 23 | private String value; 24 | 25 | @ManyToMany(mappedBy = "tagList", fetch = FetchType.EAGER) 26 | private Set
articles = new HashSet
(); 27 | 28 | public Tag() {} 29 | 30 | public Tag(String value) { 31 | this.value = value; 32 | } 33 | 34 | public String getId() { 35 | return id; 36 | } 37 | 38 | public String getValue() { 39 | return value; 40 | } 41 | 42 | public void setValue(String value) { 43 | this.value = value; 44 | } 45 | 46 | public Collection
getArticles() { 47 | return articles; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/main/java/com/marcusmonteirodesouza/realworld/api/articles/repositories/articles/ArticlesRepository.java: -------------------------------------------------------------------------------- 1 | package com.marcusmonteirodesouza.realworld.api.articles.repositories.articles; 2 | 3 | import com.marcusmonteirodesouza.realworld.api.articles.models.Article; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | import org.springframework.data.jpa.repository.JpaSpecificationExecutor; 6 | 7 | public interface ArticlesRepository 8 | extends JpaRepository, JpaSpecificationExecutor
{ 9 | public Article getArticleBySlug(String slug); 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/com/marcusmonteirodesouza/realworld/api/articles/repositories/articles/specifications/ArticlesSpecifications.java: -------------------------------------------------------------------------------- 1 | package com.marcusmonteirodesouza.realworld.api.articles.repositories.articles.specifications; 2 | 3 | import com.marcusmonteirodesouza.realworld.api.articles.models.Article; 4 | import com.marcusmonteirodesouza.realworld.api.articles.models.Tag; 5 | import jakarta.persistence.criteria.Join; 6 | import jakarta.persistence.criteria.JoinType; 7 | import org.springframework.data.jpa.domain.Specification; 8 | 9 | public class ArticlesSpecifications { 10 | public static Specification
hasTag(String tagValue) { 11 | return (root, query, builder) -> { 12 | Join tags = root.join("tagList", JoinType.INNER); 13 | return builder.equal(tags.get("value"), tagValue); 14 | }; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/com/marcusmonteirodesouza/realworld/api/articles/repositories/comments/CommentsRepository.java: -------------------------------------------------------------------------------- 1 | package com.marcusmonteirodesouza.realworld.api.articles.repositories.comments; 2 | 3 | import com.marcusmonteirodesouza.realworld.api.articles.models.Article; 4 | import com.marcusmonteirodesouza.realworld.api.articles.models.Comment; 5 | import java.util.List; 6 | import org.springframework.data.jpa.repository.JpaRepository; 7 | 8 | public interface CommentsRepository extends JpaRepository { 9 | List findByArticleOrderByCreatedAtDesc(Article article); 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/com/marcusmonteirodesouza/realworld/api/articles/repositories/tags/TagsRepository.java: -------------------------------------------------------------------------------- 1 | package com.marcusmonteirodesouza.realworld.api.articles.repositories.tags; 2 | 3 | import com.marcusmonteirodesouza.realworld.api.articles.models.Tag; 4 | import java.util.Optional; 5 | import org.springframework.data.jpa.repository.JpaRepository; 6 | 7 | public interface TagsRepository extends JpaRepository { 8 | Optional findByValue(String value); 9 | } 10 | -------------------------------------------------------------------------------- /src/main/java/com/marcusmonteirodesouza/realworld/api/articles/services/ArticlesService.java: -------------------------------------------------------------------------------- 1 | package com.marcusmonteirodesouza.realworld.api.articles.services; 2 | 3 | import com.github.slugify.Slugify; 4 | import com.google.common.base.CaseFormat; 5 | import com.marcusmonteirodesouza.realworld.api.articles.models.Article; 6 | import com.marcusmonteirodesouza.realworld.api.articles.models.Comment; 7 | import com.marcusmonteirodesouza.realworld.api.articles.models.Favorite; 8 | import com.marcusmonteirodesouza.realworld.api.articles.models.Tag; 9 | import com.marcusmonteirodesouza.realworld.api.articles.repositories.articles.ArticlesRepository; 10 | import com.marcusmonteirodesouza.realworld.api.articles.repositories.comments.CommentsRepository; 11 | import com.marcusmonteirodesouza.realworld.api.articles.repositories.tags.TagsRepository; 12 | import com.marcusmonteirodesouza.realworld.api.articles.services.parameterobjects.ArticleCreate; 13 | import com.marcusmonteirodesouza.realworld.api.articles.services.parameterobjects.ArticleUpdate; 14 | import com.marcusmonteirodesouza.realworld.api.articles.services.parameterobjects.ArticlesList; 15 | import com.marcusmonteirodesouza.realworld.api.exceptions.AlreadyExistsException; 16 | import com.marcusmonteirodesouza.realworld.api.users.services.users.UsersService; 17 | import jakarta.persistence.EntityManager; 18 | import jakarta.persistence.EntityNotFoundException; 19 | import jakarta.ws.rs.NotFoundException; 20 | import java.lang.invoke.MethodHandles; 21 | import java.util.HashSet; 22 | import java.util.List; 23 | import java.util.Optional; 24 | import java.util.stream.Collectors; 25 | import org.slf4j.Logger; 26 | import org.slf4j.LoggerFactory; 27 | import org.springframework.stereotype.Service; 28 | 29 | @Service 30 | public class ArticlesService { 31 | private final Logger logger = 32 | LoggerFactory.getLogger(MethodHandles.lookup().lookupClass().getName()); 33 | 34 | private final UsersService usersService; 35 | private final ArticlesRepository articlesRepository; 36 | private final TagsRepository tagsRepository; 37 | private final CommentsRepository commentsRepository; 38 | private final EntityManager entityManager; 39 | private final Slugify slg = Slugify.builder().build(); 40 | 41 | public ArticlesService( 42 | UsersService usersService, 43 | ArticlesRepository articlesRepository, 44 | TagsRepository tagsRepository, 45 | CommentsRepository commentsRepository, 46 | EntityManager entityManager) { 47 | this.usersService = usersService; 48 | this.articlesRepository = articlesRepository; 49 | this.tagsRepository = tagsRepository; 50 | this.commentsRepository = commentsRepository; 51 | this.entityManager = entityManager; 52 | } 53 | 54 | public Article createArticle(ArticleCreate articleCreate) throws AlreadyExistsException { 55 | logger.info( 56 | "Creating article. Author ID: " 57 | + articleCreate.getAuthorId() 58 | + ", Title: " 59 | + articleCreate.getTitle() 60 | + ", Description: " 61 | + articleCreate.getDescription() 62 | + ", Body: " 63 | + articleCreate.getBody() 64 | + ", TagList" 65 | + articleCreate.getTagList()); 66 | 67 | var author = usersService.getUserById(articleCreate.getAuthorId()).orElse(null); 68 | 69 | if (author == null) { 70 | throw new NotFoundException("Author '" + articleCreate.getAuthorId() + "' not found"); 71 | } 72 | 73 | var slug = makeSlug(articleCreate.getTitle()); 74 | 75 | if (getArticleBySlug(slug).isPresent()) { 76 | throw new AlreadyExistsException("Article with slug '" + slug + "' already exists"); 77 | } 78 | 79 | var tagList = new HashSet(); 80 | 81 | if (articleCreate.getTagList().isPresent()) { 82 | tagList.addAll( 83 | articleCreate.getTagList().get().stream() 84 | .map(tagValue -> makeTag(tagValue)) 85 | .collect(Collectors.toSet())); 86 | } 87 | 88 | var article = new Article(); 89 | article.setAuthorId(author.getId()); 90 | article.setSlug(slug); 91 | article.setTitle(articleCreate.getTitle()); 92 | article.setDescription(articleCreate.getDescription()); 93 | article.setBody(articleCreate.getBody()); 94 | article.setTagList(tagList); 95 | 96 | return articlesRepository.saveAndFlush(article); 97 | } 98 | 99 | public Optional
getArticleById(String articleId) { 100 | try { 101 | return Optional.of(articlesRepository.getReferenceById(articleId)); 102 | } catch (EntityNotFoundException ex) { 103 | throw new NotFoundException("Article '" + articleId + "' not found"); 104 | } 105 | } 106 | 107 | public Optional
getArticleBySlug(String slug) { 108 | return Optional.ofNullable(articlesRepository.getArticleBySlug(slug)); 109 | } 110 | 111 | public List
listArticles(ArticlesList articlesList) { 112 | var criteriaBuilder = entityManager.getCriteriaBuilder(); 113 | var articleCriteriaQuery = criteriaBuilder.createQuery(Article.class); 114 | var articleRoot = articleCriteriaQuery.from(Article.class); 115 | articleCriteriaQuery.select(articleRoot); 116 | 117 | var predicate = criteriaBuilder.conjunction(); 118 | 119 | if (articlesList.getTag().isPresent()) { 120 | var joinArticleTag = articleRoot.join("tagList"); 121 | predicate = 122 | criteriaBuilder.and( 123 | predicate, 124 | criteriaBuilder.equal( 125 | joinArticleTag.get("value"), articlesList.getTag().get())); 126 | } 127 | 128 | if (articlesList.getAuthorIds().isPresent()) { 129 | predicate = 130 | criteriaBuilder.and( 131 | predicate, 132 | articleRoot.get("authorId").in(articlesList.getAuthorIds().get())); 133 | } 134 | 135 | if (articlesList.getFavoritedByUserId().isPresent()) { 136 | var joinArticleFavorite = articleRoot.join("favorites"); 137 | predicate = 138 | criteriaBuilder.and( 139 | predicate, 140 | criteriaBuilder.equal( 141 | joinArticleFavorite.get("userId"), 142 | articlesList.getFavoritedByUserId().get())); 143 | } 144 | 145 | articleCriteriaQuery.where(predicate); 146 | articleCriteriaQuery.orderBy(criteriaBuilder.desc(articleRoot.get("createdAt"))); 147 | 148 | var query = entityManager.createQuery(articleCriteriaQuery); 149 | 150 | articlesList.getLimit().ifPresent(query::setMaxResults); 151 | articlesList.getOffset().ifPresent(query::setFirstResult); 152 | 153 | var articles = query.getResultList(); 154 | 155 | for (var article : articles) { 156 | article.getTagList().stream() 157 | .collect(Collectors.toList()) 158 | .sort((tag1, tag2) -> tag1.getValue().compareTo(tag2.getValue())); 159 | } 160 | 161 | return articles; 162 | } 163 | 164 | public Article updateArticle(String articleId, ArticleUpdate articleUpdate) { 165 | logger.info( 166 | "Updating Article: '" 167 | + articleId 168 | + "'. Title: " 169 | + articleUpdate.getTitle() 170 | + ". Description: " 171 | + articleUpdate.getDescription() 172 | + ". Body: " 173 | + articleUpdate.getBody()); 174 | 175 | var article = getArticleById(articleId).orElse(null); 176 | 177 | if (article == null) { 178 | throw new NotFoundException("Article '" + articleId + "' not found"); 179 | } 180 | 181 | if (articleUpdate.getTitle().isPresent()) { 182 | var title = articleUpdate.getTitle().get(); 183 | var slug = makeSlug(title); 184 | 185 | article.setSlug(slug); 186 | article.setTitle(title); 187 | } 188 | 189 | if (articleUpdate.getDescription().isPresent()) { 190 | article.setDescription(articleUpdate.getDescription().get()); 191 | } 192 | 193 | if (articleUpdate.getBody().isPresent()) { 194 | article.setBody(articleUpdate.getBody().get()); 195 | } 196 | 197 | return articlesRepository.saveAndFlush(article); 198 | } 199 | 200 | public void deleteArticleById(String articleId) { 201 | logger.info("Deleting Article '" + articleId); 202 | 203 | if (!articlesRepository.existsById(articleId)) { 204 | throw new NotFoundException("Article '" + articleId + "' not found"); 205 | } 206 | 207 | articlesRepository.deleteById(articleId); 208 | } 209 | 210 | public Article favoriteArticle(String userId, String articleId) { 211 | logger.info("User '" + userId + "' favoriting Article '" + articleId + "'"); 212 | 213 | var article = getArticleById(articleId).orElse(null); 214 | 215 | if (article == null) { 216 | throw new NotFoundException("Article '" + articleId + "' not found"); 217 | } 218 | 219 | if (isFavorited(userId, article)) { 220 | return article; 221 | } 222 | 223 | var user = usersService.getUserById(userId).orElse(null); 224 | 225 | if (user == null) { 226 | throw new NotFoundException("User '" + userId + "' not found"); 227 | } 228 | 229 | var favorite = new Favorite(); 230 | favorite.setUserId(user.getId()); 231 | 232 | article.addFavorite(favorite); 233 | 234 | return articlesRepository.saveAndFlush(article); 235 | } 236 | 237 | public Article unfavoriteArticle(String userId, String articleId) { 238 | logger.info("User '" + userId + "' unfavoriting Article '" + articleId + "'"); 239 | 240 | var article = getArticleById(articleId).orElse(null); 241 | 242 | if (article == null) { 243 | throw new NotFoundException("Article '" + articleId + "' not found"); 244 | } 245 | 246 | if (!isFavorited(userId, article)) { 247 | return article; 248 | } 249 | 250 | var favorite = 251 | article.getFavorites().stream() 252 | .filter(f -> f.getUserId().equals(userId)) 253 | .findFirst() 254 | .get(); 255 | 256 | article.removeFavorite(favorite); 257 | 258 | return articlesRepository.saveAndFlush(article); 259 | } 260 | 261 | public Comment addCommentToArticle(String articleId, String commentAuthorId, String body) { 262 | logger.info( 263 | "Adding Comment to Article. Article: " 264 | + articleId 265 | + ", Author: " 266 | + commentAuthorId 267 | + ", Body: " 268 | + body); 269 | 270 | var article = getArticleById(articleId).orElse(null); 271 | 272 | if (article == null) { 273 | throw new NotFoundException("Article '" + articleId + "' not found"); 274 | } 275 | 276 | var user = usersService.getUserById(commentAuthorId).orElse(null); 277 | 278 | if (user == null) { 279 | throw new NotFoundException("User '" + commentAuthorId + "' not found"); 280 | } 281 | 282 | var comment = new Comment(); 283 | comment.setAuthorId(commentAuthorId); 284 | comment.setBody(body); 285 | 286 | article.addComment(comment); 287 | 288 | return commentsRepository.saveAndFlush(comment); 289 | } 290 | 291 | public Optional getCommentById(String commentId) { 292 | return commentsRepository.findById(commentId); 293 | } 294 | 295 | public List listCommentsByArticleId(String articleId) { 296 | var article = getArticleById(articleId).orElse(null); 297 | 298 | if (article == null) { 299 | throw new NotFoundException("Article '" + articleId + "' not found"); 300 | } 301 | 302 | return commentsRepository.findByArticleOrderByCreatedAtDesc(article); 303 | } 304 | 305 | public void deleteCommentById(String commentId) { 306 | logger.info("Deleting Comment '" + commentId + "'"); 307 | 308 | var comment = getCommentById(commentId).orElse(null); 309 | 310 | if (comment == null) { 311 | throw new NotFoundException("Comment '" + commentId + "' not found"); 312 | } 313 | 314 | var article = comment.getArticle(); 315 | 316 | article.removeComment(comment); 317 | 318 | articlesRepository.save(article); 319 | } 320 | 321 | public List listTags() { 322 | return tagsRepository.findAll(); 323 | } 324 | 325 | private Boolean isFavorited(String userId, Article article) { 326 | return article.getFavorites().stream() 327 | .filter(favorite -> favorite.getUserId().equals(userId)) 328 | .findFirst() 329 | .isPresent(); 330 | } 331 | 332 | private String makeSlug(String title) { 333 | return slg.slugify(title); 334 | } 335 | 336 | private Tag makeTag(String tagValue) { 337 | tagValue = tagValue.toLowerCase().trim(); 338 | tagValue = CaseFormat.LOWER_CAMEL.to(CaseFormat.LOWER_HYPHEN, tagValue); 339 | tagValue = CaseFormat.LOWER_UNDERSCORE.to(CaseFormat.LOWER_HYPHEN, tagValue); 340 | 341 | var maybeTag = tagsRepository.findByValue(tagValue); 342 | 343 | if (maybeTag.isPresent()) { 344 | return maybeTag.get(); 345 | } else { 346 | return new Tag(tagValue); 347 | } 348 | } 349 | } 350 | -------------------------------------------------------------------------------- /src/main/java/com/marcusmonteirodesouza/realworld/api/articles/services/parameterobjects/ArticleCreate.java: -------------------------------------------------------------------------------- 1 | package com.marcusmonteirodesouza.realworld.api.articles.services.parameterobjects; 2 | 3 | import java.util.Collection; 4 | import java.util.Optional; 5 | 6 | public class ArticleCreate { 7 | private final String authorId; 8 | private final String title; 9 | private final String description; 10 | private final String body; 11 | private final Optional> tagList; 12 | 13 | public ArticleCreate(String authorId, String title, String description, String body) { 14 | this.authorId = authorId; 15 | this.title = title; 16 | this.description = description; 17 | this.body = body; 18 | this.tagList = Optional.empty(); 19 | } 20 | 21 | public ArticleCreate( 22 | String authorId, 23 | String title, 24 | String description, 25 | String body, 26 | Optional> tagList) { 27 | this.authorId = authorId; 28 | this.title = title; 29 | this.description = description; 30 | this.body = body; 31 | this.tagList = tagList; 32 | } 33 | 34 | public String getAuthorId() { 35 | return authorId; 36 | } 37 | 38 | public String getTitle() { 39 | return title; 40 | } 41 | 42 | public String getDescription() { 43 | return description; 44 | } 45 | 46 | public String getBody() { 47 | return body; 48 | } 49 | 50 | public Optional> getTagList() { 51 | return tagList; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/main/java/com/marcusmonteirodesouza/realworld/api/articles/services/parameterobjects/ArticleUpdate.java: -------------------------------------------------------------------------------- 1 | package com.marcusmonteirodesouza.realworld.api.articles.services.parameterobjects; 2 | 3 | import java.util.Optional; 4 | 5 | public class ArticleUpdate { 6 | private Optional title = Optional.empty(); 7 | private Optional description = Optional.empty(); 8 | private Optional body = Optional.empty(); 9 | 10 | public ArticleUpdate() {} 11 | 12 | public ArticleUpdate( 13 | Optional title, Optional description, Optional body) { 14 | this.title = title; 15 | this.description = description; 16 | this.body = body; 17 | } 18 | 19 | public Optional getTitle() { 20 | return title; 21 | } 22 | 23 | public Optional getDescription() { 24 | return description; 25 | } 26 | 27 | public Optional getBody() { 28 | return body; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/com/marcusmonteirodesouza/realworld/api/articles/services/parameterobjects/ArticlesList.java: -------------------------------------------------------------------------------- 1 | package com.marcusmonteirodesouza.realworld.api.articles.services.parameterobjects; 2 | 3 | import java.util.Collection; 4 | import java.util.Optional; 5 | 6 | public class ArticlesList { 7 | private Optional tag = Optional.empty(); 8 | private Optional> authorIds = Optional.empty(); 9 | private Optional favoritedByUserId = Optional.empty(); 10 | private Optional limit = Optional.empty(); 11 | private Optional offset = Optional.empty(); 12 | 13 | public ArticlesList() {} 14 | 15 | public ArticlesList( 16 | Optional tag, 17 | Optional> authorIds, 18 | Optional favoritedByUserId, 19 | Optional limit, 20 | Optional offset) { 21 | this.tag = tag; 22 | this.authorIds = authorIds; 23 | this.favoritedByUserId = favoritedByUserId; 24 | this.limit = limit; 25 | this.offset = offset; 26 | } 27 | 28 | public Optional getTag() { 29 | return tag; 30 | } 31 | 32 | public Optional> getAuthorIds() { 33 | return authorIds; 34 | } 35 | 36 | public Optional getFavoritedByUserId() { 37 | return favoritedByUserId; 38 | } 39 | 40 | public Optional getLimit() { 41 | return limit; 42 | } 43 | 44 | public Optional getOffset() { 45 | return offset; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/main/java/com/marcusmonteirodesouza/realworld/api/authentication/AuthenticationFacade.java: -------------------------------------------------------------------------------- 1 | package com.marcusmonteirodesouza.realworld.api.authentication; 2 | 3 | import org.springframework.security.core.Authentication; 4 | import org.springframework.security.core.context.SecurityContextHolder; 5 | import org.springframework.stereotype.Component; 6 | 7 | @Component 8 | public class AuthenticationFacade implements IAuthenticationFacade { 9 | @Override 10 | public Authentication getAuthentication() { 11 | return SecurityContextHolder.getContext().getAuthentication(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/com/marcusmonteirodesouza/realworld/api/authentication/IAuthenticationFacade.java: -------------------------------------------------------------------------------- 1 | package com.marcusmonteirodesouza.realworld.api.authentication; 2 | 3 | import org.springframework.security.core.Authentication; 4 | 5 | public interface IAuthenticationFacade { 6 | Authentication getAuthentication(); 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/com/marcusmonteirodesouza/realworld/api/exceptionhandlers/RestResponseEntityExceptionHandler.java: -------------------------------------------------------------------------------- 1 | package com.marcusmonteirodesouza.realworld.api.exceptionhandlers; 2 | 3 | import com.marcusmonteirodesouza.realworld.api.exceptionhandlers.dto.ErrorResponse; 4 | import com.marcusmonteirodesouza.realworld.api.exceptions.AlreadyExistsException; 5 | import com.marcusmonteirodesouza.realworld.api.exceptions.ForbiddenException; 6 | import jakarta.ws.rs.NotAuthorizedException; 7 | import jakarta.ws.rs.NotFoundException; 8 | import java.lang.invoke.MethodHandles; 9 | import org.slf4j.Logger; 10 | import org.slf4j.LoggerFactory; 11 | import org.springframework.http.HttpHeaders; 12 | import org.springframework.http.HttpStatus; 13 | import org.springframework.http.HttpStatusCode; 14 | import org.springframework.http.ResponseEntity; 15 | import org.springframework.web.bind.annotation.ControllerAdvice; 16 | import org.springframework.web.bind.annotation.ExceptionHandler; 17 | import org.springframework.web.context.request.WebRequest; 18 | import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; 19 | 20 | @ControllerAdvice 21 | public class RestResponseEntityExceptionHandler extends ResponseEntityExceptionHandler { 22 | private final Logger logger = 23 | LoggerFactory.getLogger(MethodHandles.lookup().lookupClass().getName()); 24 | 25 | @ExceptionHandler(value = {Exception.class}) 26 | protected ResponseEntity handleGeneralException(Exception ex, WebRequest request) { 27 | logger.error(ex.getMessage(), ex); 28 | 29 | ErrorResponse errorResponse; 30 | HttpStatusCode statusCode; 31 | 32 | if (ex instanceof AlreadyExistsException) { 33 | errorResponse = new ErrorResponse(new String[] {ex.getMessage()}); 34 | statusCode = HttpStatus.CONFLICT; 35 | } else if (ex instanceof ForbiddenException) { 36 | errorResponse = new ErrorResponse(new String[] {ex.getMessage()}); 37 | statusCode = HttpStatus.FORBIDDEN; 38 | } else if (ex instanceof IllegalArgumentException) { 39 | errorResponse = new ErrorResponse(new String[] {ex.getMessage()}); 40 | statusCode = HttpStatus.UNPROCESSABLE_ENTITY; 41 | } else if (ex instanceof NotAuthorizedException) { 42 | errorResponse = new ErrorResponse(new String[] {"Unauthorized"}); 43 | statusCode = HttpStatus.UNAUTHORIZED; 44 | } else if (ex instanceof NotFoundException) { 45 | errorResponse = new ErrorResponse(new String[] {ex.getMessage()}); 46 | statusCode = HttpStatus.NOT_FOUND; 47 | } else { 48 | errorResponse = new ErrorResponse(new String[] {"Internal Server Error"}); 49 | statusCode = HttpStatus.INTERNAL_SERVER_ERROR; 50 | } 51 | 52 | return handleExceptionInternal(ex, errorResponse, new HttpHeaders(), statusCode, request); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/main/java/com/marcusmonteirodesouza/realworld/api/exceptionhandlers/dto/ErrorResponse.java: -------------------------------------------------------------------------------- 1 | package com.marcusmonteirodesouza.realworld.api.exceptionhandlers.dto; 2 | 3 | public class ErrorResponse { 4 | private ErrorResponseErrors errors; 5 | 6 | public ErrorResponse(String[] errors) { 7 | this.errors = new ErrorResponseErrors(errors); 8 | } 9 | 10 | public ErrorResponseErrors getErrors() { 11 | return errors; 12 | } 13 | 14 | public class ErrorResponseErrors { 15 | private String[] body; 16 | 17 | public ErrorResponseErrors(String[] errors) { 18 | this.body = errors; 19 | } 20 | 21 | public String[] getBody() { 22 | return body; 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/com/marcusmonteirodesouza/realworld/api/exceptions/AlreadyExistsException.java: -------------------------------------------------------------------------------- 1 | package com.marcusmonteirodesouza.realworld.api.exceptions; 2 | 3 | public class AlreadyExistsException extends Exception { 4 | public AlreadyExistsException(String message) { 5 | super(message); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/com/marcusmonteirodesouza/realworld/api/exceptions/ForbiddenException.java: -------------------------------------------------------------------------------- 1 | package com.marcusmonteirodesouza.realworld.api.exceptions; 2 | 3 | public class ForbiddenException extends Exception { 4 | public ForbiddenException(String message) { 5 | super(message); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/com/marcusmonteirodesouza/realworld/api/profiles/controllers/ProfilesController.java: -------------------------------------------------------------------------------- 1 | package com.marcusmonteirodesouza.realworld.api.profiles.controllers; 2 | 3 | import com.marcusmonteirodesouza.realworld.api.authentication.IAuthenticationFacade; 4 | import com.marcusmonteirodesouza.realworld.api.profiles.controllers.dto.ProfileResponse; 5 | import com.marcusmonteirodesouza.realworld.api.profiles.services.ProfilesService; 6 | import com.marcusmonteirodesouza.realworld.api.users.services.users.UsersService; 7 | import jakarta.transaction.Transactional; 8 | import jakarta.ws.rs.NotFoundException; 9 | import java.util.Optional; 10 | import org.springframework.web.bind.annotation.DeleteMapping; 11 | import org.springframework.web.bind.annotation.GetMapping; 12 | import org.springframework.web.bind.annotation.PathVariable; 13 | import org.springframework.web.bind.annotation.PostMapping; 14 | import org.springframework.web.bind.annotation.RequestMapping; 15 | import org.springframework.web.bind.annotation.RestController; 16 | 17 | @RestController 18 | @RequestMapping(path = "/profiles") 19 | public class ProfilesController { 20 | private final IAuthenticationFacade authenticationFacade; 21 | private final UsersService usersService; 22 | private final ProfilesService profilesService; 23 | 24 | public ProfilesController( 25 | IAuthenticationFacade authenticationFacade, 26 | UsersService usersService, 27 | ProfilesService profilesService) { 28 | this.authenticationFacade = authenticationFacade; 29 | this.usersService = usersService; 30 | this.profilesService = profilesService; 31 | } 32 | 33 | @PostMapping("/{username}/follow") 34 | @Transactional 35 | public ProfileResponse followUser(@PathVariable String username) { 36 | var user = usersService.getUserByUsername(username).orElse(null); 37 | 38 | if (user == null) { 39 | throw new NotFoundException("Username '" + username + "' not found"); 40 | } 41 | 42 | var authenticatedUserId = authenticationFacade.getAuthentication().getName(); 43 | 44 | profilesService.followUser(authenticatedUserId, user.getId()); 45 | 46 | var profile = profilesService.getProfile(user.getId(), Optional.of(authenticatedUserId)); 47 | 48 | return new ProfileResponse(profile); 49 | } 50 | 51 | @GetMapping("/{username}") 52 | public ProfileResponse getProfile(@PathVariable String username) { 53 | var user = usersService.getUserByUsername(username).orElse(null); 54 | 55 | if (user == null) { 56 | throw new NotFoundException("Username '" + username + "' not found"); 57 | } 58 | 59 | var authenticatedUserId = authenticationFacade.getAuthentication().getName(); 60 | 61 | var profile = 62 | profilesService.getProfile(user.getId(), Optional.ofNullable(authenticatedUserId)); 63 | 64 | return new ProfileResponse(profile); 65 | } 66 | 67 | @DeleteMapping("/{username}/follow") 68 | @Transactional 69 | public ProfileResponse unfollowUser(@PathVariable String username) { 70 | var user = usersService.getUserByUsername(username).orElse(null); 71 | 72 | if (user == null) { 73 | throw new NotFoundException("Username '" + username + "' not found"); 74 | } 75 | 76 | var authenticatedUserId = authenticationFacade.getAuthentication().getName(); 77 | 78 | profilesService.unfollowUser(authenticatedUserId, user.getId()); 79 | 80 | var profile = profilesService.getProfile(user.getId(), Optional.of(authenticatedUserId)); 81 | 82 | return new ProfileResponse(profile); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/main/java/com/marcusmonteirodesouza/realworld/api/profiles/controllers/dto/ProfileResponse.java: -------------------------------------------------------------------------------- 1 | package com.marcusmonteirodesouza.realworld.api.profiles.controllers.dto; 2 | 3 | import com.marcusmonteirodesouza.realworld.api.profiles.models.Profile; 4 | 5 | public final class ProfileResponse { 6 | private final ProfileResponseProfile profile; 7 | 8 | public ProfileResponse(Profile profile) { 9 | this.profile = new ProfileResponseProfile(profile); 10 | } 11 | 12 | public ProfileResponseProfile getProfile() { 13 | return profile; 14 | } 15 | 16 | public static final class ProfileResponseProfile { 17 | private final String username; 18 | private final String bio; 19 | private final String image; 20 | private final Boolean following; 21 | 22 | public ProfileResponseProfile(Profile profile) { 23 | this.username = profile.getUsername(); 24 | this.bio = profile.getBio().orElse(null); 25 | this.image = profile.getImage().orElse(null); 26 | this.following = profile.getFollowing(); 27 | } 28 | 29 | public String getUsername() { 30 | return username; 31 | } 32 | 33 | public String getBio() { 34 | return bio; 35 | } 36 | 37 | public String getImage() { 38 | return image; 39 | } 40 | 41 | public Boolean getFollowing() { 42 | return following; 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/main/java/com/marcusmonteirodesouza/realworld/api/profiles/models/Follow.java: -------------------------------------------------------------------------------- 1 | package com.marcusmonteirodesouza.realworld.api.profiles.models; 2 | 3 | import jakarta.persistence.Entity; 4 | import jakarta.persistence.GeneratedValue; 5 | import jakarta.persistence.GenerationType; 6 | import jakarta.persistence.Id; 7 | import jakarta.persistence.Table; 8 | import jakarta.persistence.UniqueConstraint; 9 | import jakarta.validation.constraints.NotBlank; 10 | import java.sql.Timestamp; 11 | import org.hibernate.annotations.CreationTimestamp; 12 | 13 | @Entity 14 | @Table(uniqueConstraints = {@UniqueConstraint(columnNames = {"followerId", "followedId"})}) 15 | public class Follow { 16 | @Id 17 | @GeneratedValue(strategy = GenerationType.UUID) 18 | private String id; 19 | 20 | @NotBlank private String followerId; 21 | 22 | @NotBlank private String followedId; 23 | 24 | @CreationTimestamp private Timestamp createdAt; 25 | 26 | public String getId() { 27 | return id; 28 | } 29 | 30 | public String getFollowerId() { 31 | return followerId; 32 | } 33 | 34 | public void setFollowerId(String followerId) { 35 | this.followerId = followerId; 36 | } 37 | 38 | public String getFollowedId() { 39 | return followedId; 40 | } 41 | 42 | public void setFollowedId(String followedId) { 43 | this.followedId = followedId; 44 | } 45 | 46 | public Timestamp getCreatedAt() { 47 | return createdAt; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/main/java/com/marcusmonteirodesouza/realworld/api/profiles/models/Profile.java: -------------------------------------------------------------------------------- 1 | package com.marcusmonteirodesouza.realworld.api.profiles.models; 2 | 3 | import com.marcusmonteirodesouza.realworld.api.users.models.User; 4 | import java.util.Optional; 5 | 6 | public class Profile { 7 | private final String userId; 8 | private final String username; 9 | private final Optional bio; 10 | private final Optional image; 11 | private final Boolean following; 12 | 13 | public Profile( 14 | String userId, 15 | String username, 16 | Optional bio, 17 | Optional image, 18 | Boolean following) { 19 | this.userId = userId; 20 | this.username = username; 21 | this.bio = bio; 22 | this.image = image; 23 | this.following = following; 24 | } 25 | 26 | public Profile(User user, Boolean following) { 27 | this(user.getId(), user.getUsername(), user.getBio(), user.getImage(), following); 28 | } 29 | 30 | public String getUserId() { 31 | return userId; 32 | } 33 | 34 | public String getUsername() { 35 | return username; 36 | } 37 | 38 | public Optional getBio() { 39 | return bio; 40 | } 41 | 42 | public Optional getImage() { 43 | return image; 44 | } 45 | 46 | public Boolean getFollowing() { 47 | return following; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/main/java/com/marcusmonteirodesouza/realworld/api/profiles/repositories/FollowsRepository.java: -------------------------------------------------------------------------------- 1 | package com.marcusmonteirodesouza.realworld.api.profiles.repositories; 2 | 3 | import com.marcusmonteirodesouza.realworld.api.profiles.models.Follow; 4 | import java.util.List; 5 | import org.springframework.data.jpa.repository.JpaRepository; 6 | 7 | public interface FollowsRepository extends JpaRepository { 8 | Boolean existsByFollowerIdAndFollowedId(String followerId, String followedId); 9 | 10 | List findByFollowerId(String followerId); 11 | 12 | void deleteByFollowerIdAndFollowedId(String followerId, String followedId); 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/com/marcusmonteirodesouza/realworld/api/profiles/services/ProfilesService.java: -------------------------------------------------------------------------------- 1 | package com.marcusmonteirodesouza.realworld.api.profiles.services; 2 | 3 | import com.marcusmonteirodesouza.realworld.api.profiles.models.Follow; 4 | import com.marcusmonteirodesouza.realworld.api.profiles.models.Profile; 5 | import com.marcusmonteirodesouza.realworld.api.profiles.repositories.FollowsRepository; 6 | import com.marcusmonteirodesouza.realworld.api.users.services.users.UsersService; 7 | import jakarta.ws.rs.NotFoundException; 8 | import java.lang.invoke.MethodHandles; 9 | import java.util.List; 10 | import java.util.Optional; 11 | import java.util.Set; 12 | import java.util.stream.Collectors; 13 | import org.slf4j.Logger; 14 | import org.slf4j.LoggerFactory; 15 | import org.springframework.stereotype.Service; 16 | 17 | @Service 18 | public class ProfilesService { 19 | private final Logger logger = 20 | LoggerFactory.getLogger(MethodHandles.lookup().lookupClass().getName()); 21 | 22 | private final UsersService usersService; 23 | private final FollowsRepository followsRepository; 24 | 25 | public ProfilesService(UsersService usersService, FollowsRepository followsRepository) { 26 | this.usersService = usersService; 27 | this.followsRepository = followsRepository; 28 | } 29 | 30 | public Profile getProfile(String userId, Optional followerId) { 31 | var user = usersService.getUserById(userId).orElse(null); 32 | 33 | if (user == null) { 34 | throw new NotFoundException("User '" + userId + "' not found"); 35 | } 36 | 37 | var following = false; 38 | 39 | if (followerId.isPresent()) { 40 | following = this.isFollowing(followerId.get(), user.getId()); 41 | } 42 | 43 | return new Profile(user, following); 44 | } 45 | 46 | public List listProfilesFollowedByUserId(String userId) { 47 | Set followedUserIds = 48 | followsRepository.findByFollowerId(userId).stream() 49 | .map(follow -> follow.getFollowedId()) 50 | .collect(Collectors.toSet()); 51 | 52 | return usersService.listUsers().stream() 53 | .filter(user -> followedUserIds.contains(user.getId())) 54 | .map(user -> new Profile(user, true)) 55 | .collect(Collectors.toList()); 56 | } 57 | 58 | public void followUser(String followerId, String followedId) { 59 | if (isFollowing(followerId, followedId)) { 60 | return; 61 | } 62 | 63 | logger.info("Creating Follow. Follower: " + followerId + ", Followed: " + followedId); 64 | 65 | var follower = usersService.getUserById(followerId).orElse(null); 66 | 67 | if (follower == null) { 68 | throw new NotFoundException("Follower '" + followerId + "' not found"); 69 | } 70 | 71 | var followed = usersService.getUserById(followedId).orElse(null); 72 | 73 | if (followed == null) { 74 | throw new NotFoundException("Followed '" + followedId + "' not found"); 75 | } 76 | 77 | var follow = new Follow(); 78 | follow.setFollowerId(follower.getId()); 79 | follow.setFollowedId(followed.getId()); 80 | 81 | this.followsRepository.save(follow); 82 | } 83 | 84 | public void unfollowUser(String followerId, String followedId) { 85 | if (!isFollowing(followerId, followedId)) { 86 | return; 87 | } 88 | 89 | logger.info("Deleting Follow. Follower: " + followerId + ", Followed: " + followedId); 90 | 91 | followsRepository.deleteByFollowerIdAndFollowedId(followerId, followedId); 92 | } 93 | 94 | private Boolean isFollowing(String followerId, String followedId) { 95 | return this.followsRepository.existsByFollowerIdAndFollowedId(followerId, followedId); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/main/java/com/marcusmonteirodesouza/realworld/api/security/SecurityConfig.java: -------------------------------------------------------------------------------- 1 | package com.marcusmonteirodesouza.realworld.api.security; 2 | 3 | import org.springframework.context.annotation.Bean; 4 | import org.springframework.context.annotation.Configuration; 5 | import org.springframework.http.HttpHeaders; 6 | import org.springframework.http.HttpMethod; 7 | import org.springframework.security.config.Customizer; 8 | import org.springframework.security.config.annotation.web.builders.HttpSecurity; 9 | import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; 10 | import org.springframework.security.config.http.SessionCreationPolicy; 11 | import org.springframework.security.web.SecurityFilterChain; 12 | 13 | @Configuration 14 | @EnableWebSecurity 15 | public class SecurityConfig { 16 | @Bean 17 | public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { 18 | http.csrf(csrfCustomizer -> csrfCustomizer.disable()) 19 | .authorizeHttpRequests( 20 | authorizeHttpRequests -> 21 | authorizeHttpRequests 22 | .requestMatchers(HttpMethod.POST, "/users") 23 | .permitAll() 24 | .requestMatchers(HttpMethod.POST, "/users/login") 25 | .permitAll() 26 | .requestMatchers(HttpMethod.GET, "/profiles/*") 27 | .permitAll() 28 | .requestMatchers(HttpMethod.GET, "/articles") 29 | .permitAll() 30 | .requestMatchers(HttpMethod.GET, "/articles/*") 31 | .permitAll() 32 | .requestMatchers(HttpMethod.GET, "/articles/*/comments") 33 | .permitAll() 34 | .requestMatchers(HttpMethod.GET, "/tags") 35 | .permitAll() 36 | .anyRequest() 37 | .authenticated()); 38 | 39 | http.sessionManagement( 40 | sessionManagementCustomizer -> 41 | sessionManagementCustomizer.sessionCreationPolicy( 42 | SessionCreationPolicy.STATELESS)); 43 | 44 | http.oauth2ResourceServer( 45 | oauth2ResourceServerCustomizer -> 46 | oauth2ResourceServerCustomizer 47 | .bearerTokenResolver( 48 | httpServletRequest -> { 49 | var header = 50 | httpServletRequest.getHeader( 51 | HttpHeaders.AUTHORIZATION); 52 | 53 | if (header == null || header.isBlank()) { 54 | return header; 55 | } 56 | 57 | var token = header.split("Token ")[1].trim(); 58 | 59 | return token; 60 | }) 61 | .jwt(Customizer.withDefaults())); 62 | 63 | return http.build(); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/main/java/com/marcusmonteirodesouza/realworld/api/users/controllers/UsersController.java: -------------------------------------------------------------------------------- 1 | package com.marcusmonteirodesouza.realworld.api.users.controllers; 2 | 3 | import com.marcusmonteirodesouza.realworld.api.authentication.IAuthenticationFacade; 4 | import com.marcusmonteirodesouza.realworld.api.exceptions.AlreadyExistsException; 5 | import com.marcusmonteirodesouza.realworld.api.users.controllers.dto.LoginRequest; 6 | import com.marcusmonteirodesouza.realworld.api.users.controllers.dto.RegisterUserRequest; 7 | import com.marcusmonteirodesouza.realworld.api.users.controllers.dto.UpdateUserRequest; 8 | import com.marcusmonteirodesouza.realworld.api.users.controllers.dto.UserResponse; 9 | import com.marcusmonteirodesouza.realworld.api.users.services.users.UsersService; 10 | import com.marcusmonteirodesouza.realworld.api.users.services.users.parameterobjects.UserUpdate; 11 | import jakarta.transaction.Transactional; 12 | import jakarta.ws.rs.NotFoundException; 13 | import java.util.Optional; 14 | import org.springframework.http.HttpStatus; 15 | import org.springframework.web.bind.annotation.GetMapping; 16 | import org.springframework.web.bind.annotation.PostMapping; 17 | import org.springframework.web.bind.annotation.PutMapping; 18 | import org.springframework.web.bind.annotation.RequestBody; 19 | import org.springframework.web.bind.annotation.RequestHeader; 20 | import org.springframework.web.bind.annotation.ResponseStatus; 21 | import org.springframework.web.bind.annotation.RestController; 22 | 23 | @RestController 24 | public class UsersController { 25 | private final IAuthenticationFacade authenticationFacade; 26 | private final UsersService usersService; 27 | 28 | public UsersController(IAuthenticationFacade authenticationFacade, UsersService usersService) { 29 | this.authenticationFacade = authenticationFacade; 30 | this.usersService = usersService; 31 | } 32 | 33 | @PostMapping("/users") 34 | @ResponseStatus(HttpStatus.CREATED) 35 | public UserResponse registerUser(@RequestBody RegisterUserRequest request) 36 | throws AlreadyExistsException { 37 | var user = 38 | usersService.createUser( 39 | request.user.username, request.user.email, request.user.password); 40 | 41 | var token = usersService.getToken(user.getUsername(), request.user.password); 42 | 43 | return new UserResponse(user, token); 44 | } 45 | 46 | @PostMapping("/users/login") 47 | public UserResponse login(@RequestBody LoginRequest request) { 48 | var user = usersService.getUserByEmail(request.user.email).orElse(null); 49 | 50 | if (user == null) { 51 | throw new NotFoundException("User with email '" + request.user.email + "' not found"); 52 | } 53 | 54 | var token = usersService.getToken(user.getUsername(), request.user.password); 55 | 56 | return new UserResponse(user, token); 57 | } 58 | 59 | @GetMapping("/user") 60 | public UserResponse getCurrentUser( 61 | @RequestHeader(name = "Authorization") String authorizationHeader) { 62 | var authentication = authenticationFacade.getAuthentication(); 63 | 64 | var userId = authentication.getName(); 65 | 66 | var user = usersService.getUserById(userId).orElse(null); 67 | 68 | if (user == null) { 69 | throw new NotFoundException("User with ID '" + userId + "' not found"); 70 | } 71 | 72 | var token = authorizationHeader.split(" ")[1]; 73 | 74 | return new UserResponse(user, token); 75 | } 76 | 77 | @PutMapping("/user") 78 | @Transactional 79 | public UserResponse updateUser( 80 | @RequestHeader(name = "Authorization") String authorizationHeader, 81 | @RequestBody UpdateUserRequest request) 82 | throws AlreadyExistsException { 83 | var authentication = authenticationFacade.getAuthentication(); 84 | 85 | var userId = authentication.getName(); 86 | 87 | var user = 88 | usersService 89 | .updateUser( 90 | userId, 91 | new UserUpdate( 92 | Optional.ofNullable(request.user.email), 93 | Optional.ofNullable(request.user.username), 94 | Optional.ofNullable(request.user.password), 95 | Optional.ofNullable(request.user.bio), 96 | Optional.ofNullable(request.user.image))) 97 | .orElse(null); 98 | 99 | if (user == null) { 100 | throw new NotFoundException("User with ID '" + userId + "' not found"); 101 | } 102 | 103 | var token = authorizationHeader.split(" ")[1]; 104 | 105 | return new UserResponse(user, token); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/main/java/com/marcusmonteirodesouza/realworld/api/users/controllers/dto/LoginRequest.java: -------------------------------------------------------------------------------- 1 | package com.marcusmonteirodesouza.realworld.api.users.controllers.dto; 2 | 3 | public final class LoginRequest { 4 | public LoginRequestUser user; 5 | 6 | public LoginRequest() {} 7 | 8 | public LoginRequest(LoginRequestUser user) { 9 | this.user = user; 10 | } 11 | 12 | public static class LoginRequestUser { 13 | public String email; 14 | public String password; 15 | 16 | public LoginRequestUser() {} 17 | 18 | public LoginRequestUser(String email, String password) { 19 | this.email = email; 20 | this.password = password; 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/com/marcusmonteirodesouza/realworld/api/users/controllers/dto/RegisterUserRequest.java: -------------------------------------------------------------------------------- 1 | package com.marcusmonteirodesouza.realworld.api.users.controllers.dto; 2 | 3 | public final class RegisterUserRequest { 4 | public RegisterUserRequestUser user; 5 | 6 | public RegisterUserRequest() {} 7 | 8 | public RegisterUserRequest(RegisterUserRequestUser user) { 9 | this.user = user; 10 | } 11 | 12 | public static final class RegisterUserRequestUser { 13 | public String username; 14 | public String email; 15 | public String password; 16 | 17 | public RegisterUserRequestUser() {} 18 | 19 | public RegisterUserRequestUser(String username, String email, String password) { 20 | this.username = username; 21 | this.email = email; 22 | this.password = password; 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/com/marcusmonteirodesouza/realworld/api/users/controllers/dto/UpdateUserRequest.java: -------------------------------------------------------------------------------- 1 | package com.marcusmonteirodesouza.realworld.api.users.controllers.dto; 2 | 3 | public final class UpdateUserRequest { 4 | public UpdateUserRequestUser user; 5 | 6 | public UpdateUserRequest() {} 7 | 8 | public UpdateUserRequest(UpdateUserRequestUser user) { 9 | this.user = user; 10 | } 11 | 12 | public static final class UpdateUserRequestUser { 13 | public String email; 14 | public String username; 15 | public String password; 16 | public String bio; 17 | public String image; 18 | 19 | public UpdateUserRequestUser() {} 20 | 21 | public UpdateUserRequestUser( 22 | String email, String username, String password, String bio, String image) { 23 | this.email = email; 24 | this.username = username; 25 | this.password = password; 26 | this.bio = bio; 27 | this.image = image; 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/com/marcusmonteirodesouza/realworld/api/users/controllers/dto/UserResponse.java: -------------------------------------------------------------------------------- 1 | package com.marcusmonteirodesouza.realworld.api.users.controllers.dto; 2 | 3 | import com.marcusmonteirodesouza.realworld.api.users.models.User; 4 | 5 | public final class UserResponse { 6 | private final UserResponseUser user; 7 | 8 | public UserResponse(User user, String token) { 9 | this.user = new UserResponseUser(user, token); 10 | } 11 | 12 | public UserResponseUser getUser() { 13 | return user; 14 | } 15 | 16 | public static final class UserResponseUser { 17 | private final String email; 18 | private final String username; 19 | private final String token; 20 | private final String bio; 21 | private final String image; 22 | 23 | public UserResponseUser(User user, String token) { 24 | this.email = user.getEmail(); 25 | this.username = user.getUsername(); 26 | this.token = token; 27 | this.bio = user.getBio().orElse(null); 28 | this.image = user.getImage().orElse(null); 29 | } 30 | 31 | public String getEmail() { 32 | return email; 33 | } 34 | 35 | public String getUsername() { 36 | return username; 37 | } 38 | 39 | public String getToken() { 40 | return token; 41 | } 42 | 43 | public String getBio() { 44 | return bio; 45 | } 46 | 47 | public String getImage() { 48 | return image; 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/main/java/com/marcusmonteirodesouza/realworld/api/users/models/User.java: -------------------------------------------------------------------------------- 1 | package com.marcusmonteirodesouza.realworld.api.users.models; 2 | 3 | import java.util.Optional; 4 | import org.keycloak.representations.idm.UserRepresentation; 5 | 6 | public class User { 7 | private final String id; 8 | private final String email; 9 | private final String username; 10 | private final Optional bio; 11 | private final Optional image; 12 | 13 | public User( 14 | String id, 15 | String email, 16 | String username, 17 | Optional bio, 18 | Optional image) { 19 | this.id = id; 20 | this.email = email; 21 | this.username = username; 22 | this.bio = bio; 23 | this.image = image; 24 | } 25 | 26 | public User(UserRepresentation userRepresentation) { 27 | this( 28 | userRepresentation.getId(), 29 | userRepresentation.getEmail(), 30 | userRepresentation.getUsername(), 31 | Optional.ofNullable(userRepresentation.firstAttribute("bio")), 32 | Optional.ofNullable(userRepresentation.firstAttribute("image"))); 33 | } 34 | 35 | public String getId() { 36 | return id; 37 | } 38 | 39 | public String getEmail() { 40 | return email; 41 | } 42 | 43 | public String getUsername() { 44 | return username; 45 | } 46 | 47 | public Optional getBio() { 48 | return bio; 49 | } 50 | 51 | public Optional getImage() { 52 | return image; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/main/java/com/marcusmonteirodesouza/realworld/api/users/services/users/UsersService.java: -------------------------------------------------------------------------------- 1 | package com.marcusmonteirodesouza.realworld.api.users.services.users; 2 | 3 | import com.marcusmonteirodesouza.realworld.api.exceptions.AlreadyExistsException; 4 | import com.marcusmonteirodesouza.realworld.api.users.models.User; 5 | import com.marcusmonteirodesouza.realworld.api.users.services.users.parameterobjects.UserUpdate; 6 | import jakarta.annotation.PostConstruct; 7 | import jakarta.annotation.PreDestroy; 8 | import java.lang.invoke.MethodHandles; 9 | import java.util.List; 10 | import java.util.Optional; 11 | import java.util.stream.Collectors; 12 | import org.apache.commons.validator.routines.EmailValidator; 13 | import org.apache.commons.validator.routines.UrlValidator; 14 | import org.keycloak.OAuth2Constants; 15 | import org.keycloak.admin.client.CreatedResponseUtil; 16 | import org.keycloak.admin.client.Keycloak; 17 | import org.keycloak.admin.client.KeycloakBuilder; 18 | import org.keycloak.representations.idm.CredentialRepresentation; 19 | import org.keycloak.representations.idm.UserRepresentation; 20 | import org.slf4j.Logger; 21 | import org.slf4j.LoggerFactory; 22 | import org.springframework.beans.factory.annotation.Value; 23 | import org.springframework.stereotype.Service; 24 | 25 | @Service 26 | public class UsersService { 27 | private final Logger logger = 28 | LoggerFactory.getLogger(MethodHandles.lookup().lookupClass().getName()); 29 | 30 | private Keycloak keycloakAdminInstance; 31 | 32 | @Value("${keycloak.admin-password}") 33 | private String keycloakAdminPassword; 34 | 35 | @Value("${keycloak.admin-username}") 36 | private String keycloakAdminUsername; 37 | 38 | @Value("${keycloak.client-id}") 39 | private String keycloakClientId; 40 | 41 | @Value("${keycloak.client-secret}") 42 | private String keycloakClientSecret; 43 | 44 | @Value("${keycloak.realm}") 45 | private String keycloakRealm; 46 | 47 | @Value("${keycloak.server-url}") 48 | private String keycloakServerUrl; 49 | 50 | @PostConstruct 51 | public void initKeycloak() { 52 | keycloakAdminInstance = 53 | KeycloakBuilder.builder() 54 | .serverUrl(keycloakServerUrl) 55 | .realm(keycloakRealm) 56 | .clientId(keycloakClientId) 57 | .clientSecret(keycloakClientSecret) 58 | .grantType(OAuth2Constants.PASSWORD) 59 | .username(keycloakAdminUsername) 60 | .password(keycloakAdminPassword) 61 | .build(); 62 | } 63 | 64 | @PreDestroy 65 | public void closeKeycloak() { 66 | keycloakAdminInstance.close(); 67 | } 68 | 69 | public User createUser(String username, String email, String password) 70 | throws AlreadyExistsException { 71 | logger.info("Creating User. Username: " + username + ", email: " + email); 72 | 73 | validateUsername(username); 74 | 75 | validateEmail(email); 76 | 77 | var usersResource = keycloakAdminInstance.realm(keycloakRealm).users(); 78 | 79 | var userRepresentation = new UserRepresentation(); 80 | userRepresentation.setUsername(username); 81 | userRepresentation.setEmail(email); 82 | userRepresentation.setEnabled(true); 83 | 84 | var createUserResponse = usersResource.create(userRepresentation); 85 | 86 | var userId = CreatedResponseUtil.getCreatedId(createUserResponse); 87 | 88 | var passwordCredentialRepresentation = makePasswordCredentialRepresentation(password); 89 | 90 | var userResource = usersResource.get(userId); 91 | 92 | userResource.resetPassword(passwordCredentialRepresentation); 93 | 94 | return new User(userRepresentation); 95 | } 96 | 97 | public Optional getUserById(String userId) { 98 | var usersResource = keycloakAdminInstance.realm(keycloakRealm).users(); 99 | 100 | var userRepresentation = usersResource.get(userId).toRepresentation(); 101 | 102 | return Optional.of(new User(userRepresentation)); 103 | } 104 | 105 | public Optional getUserByEmail(String email) { 106 | var usersResource = keycloakAdminInstance.realm(keycloakRealm).users(); 107 | 108 | var usersByEmail = usersResource.searchByEmail(email, true); 109 | 110 | if (usersByEmail.isEmpty()) { 111 | return Optional.empty(); 112 | } 113 | 114 | var userRepresentation = usersByEmail.getFirst(); 115 | 116 | return Optional.of(new User(userRepresentation)); 117 | } 118 | 119 | public Optional getUserByUsername(String username) { 120 | var usersResource = keycloakAdminInstance.realm(keycloakRealm).users(); 121 | 122 | var usersByEmail = usersResource.searchByUsername(username, true); 123 | 124 | if (usersByEmail.isEmpty()) { 125 | return Optional.empty(); 126 | } 127 | 128 | var userRepresentation = usersByEmail.getFirst(); 129 | 130 | return Optional.of(new User(userRepresentation)); 131 | } 132 | 133 | public List listUsers() { 134 | var usersResource = keycloakAdminInstance.realm(keycloakRealm).users(); 135 | 136 | var userRepresentations = usersResource.list(); 137 | 138 | return userRepresentations.stream() 139 | .map(userRepresentation -> new User(userRepresentation)) 140 | .collect(Collectors.toList()); 141 | } 142 | 143 | public String getToken(String username, String password) { 144 | var keycloakInstance = 145 | Keycloak.getInstance( 146 | keycloakServerUrl, 147 | keycloakRealm, 148 | username, 149 | password, 150 | keycloakClientId, 151 | keycloakClientSecret); 152 | 153 | try { 154 | return keycloakInstance.tokenManager().grantToken().getToken(); 155 | } finally { 156 | keycloakInstance.close(); 157 | } 158 | } 159 | 160 | public Optional updateUser(String userId, UserUpdate userUpdate) 161 | throws AlreadyExistsException { 162 | logger.info( 163 | "Updating User: " 164 | + userId 165 | + ". Username: " 166 | + userUpdate.getUsername() 167 | + ", Email: " 168 | + userUpdate.getEmail() 169 | + ", Bio: " 170 | + userUpdate.getBio() 171 | + ", Image: " 172 | + userUpdate.getImage() 173 | + ", Updating Password: " 174 | + userUpdate.getPassword().isPresent()); 175 | 176 | var usersResource = keycloakAdminInstance.realm(keycloakRealm).users(); 177 | 178 | var userResource = usersResource.get(userId); 179 | 180 | var userRepresentation = userResource.toRepresentation(); 181 | 182 | if (userUpdate.getUsername().isPresent()) { 183 | var username = userUpdate.getUsername().get(); 184 | 185 | if (!username.equals(userRepresentation.getUsername())) { 186 | validateUsername(username); 187 | userRepresentation.setUsername(username); 188 | } 189 | } 190 | 191 | if (userUpdate.getEmail().isPresent()) { 192 | var email = userUpdate.getEmail().get(); 193 | 194 | if (!email.equals(userRepresentation.getEmail())) { 195 | validateEmail(email); 196 | userRepresentation.setEmail(email); 197 | } 198 | } 199 | 200 | if (userUpdate.getBio().isPresent()) { 201 | userRepresentation.singleAttribute("bio", userUpdate.getBio().get()); 202 | } 203 | 204 | if (userUpdate.getImage().isPresent()) { 205 | var image = userUpdate.getImage().get(); 206 | validateImage(image); 207 | userRepresentation.singleAttribute("image", image); 208 | } 209 | 210 | userResource.update(userRepresentation); 211 | 212 | if (userUpdate.getPassword().isPresent()) { 213 | var passwordCredentialRepresentation = 214 | makePasswordCredentialRepresentation(userUpdate.getPassword().get()); 215 | userResource.resetPassword(passwordCredentialRepresentation); 216 | } 217 | 218 | return Optional.of( 219 | new User( 220 | userRepresentation.getId(), 221 | userRepresentation.getEmail(), 222 | userRepresentation.getUsername(), 223 | Optional.ofNullable(userRepresentation.firstAttribute("bio")), 224 | Optional.ofNullable(userRepresentation.firstAttribute("image")))); 225 | } 226 | 227 | private void validateUsername(String username) throws AlreadyExistsException { 228 | var usersResource = keycloakAdminInstance.realm(keycloakRealm).users(); 229 | 230 | var usersByUsername = usersResource.searchByUsername(username, true); 231 | if (!usersByUsername.isEmpty()) { 232 | throw new AlreadyExistsException("Username '" + username + "' is taken"); 233 | } 234 | } 235 | 236 | private void validateEmail(String email) throws AlreadyExistsException { 237 | if (!EmailValidator.getInstance().isValid(email)) { 238 | throw new IllegalArgumentException("Invalid email: " + email); 239 | } 240 | 241 | var usersResource = keycloakAdminInstance.realm(keycloakRealm).users(); 242 | 243 | var usersByEmail = usersResource.searchByEmail(email, true); 244 | if (!usersByEmail.isEmpty()) { 245 | throw new AlreadyExistsException("Email '" + email + "' is taken"); 246 | } 247 | } 248 | 249 | private void validateImage(String image) { 250 | if (!UrlValidator.getInstance().isValid(image)) { 251 | throw new IllegalArgumentException("Invalid image URL: " + image); 252 | } 253 | } 254 | 255 | private CredentialRepresentation makePasswordCredentialRepresentation(String password) { 256 | var passwordCredentialRepresentation = new CredentialRepresentation(); 257 | 258 | passwordCredentialRepresentation.setTemporary(false); 259 | passwordCredentialRepresentation.setType(CredentialRepresentation.PASSWORD); 260 | passwordCredentialRepresentation.setValue(password); 261 | 262 | return passwordCredentialRepresentation; 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /src/main/java/com/marcusmonteirodesouza/realworld/api/users/services/users/parameterobjects/UserUpdate.java: -------------------------------------------------------------------------------- 1 | package com.marcusmonteirodesouza.realworld.api.users.services.users.parameterobjects; 2 | 3 | import java.util.Optional; 4 | 5 | public final class UserUpdate { 6 | private Optional email = Optional.empty(); 7 | private Optional username = Optional.empty(); 8 | private Optional password = Optional.empty(); 9 | private Optional bio = Optional.empty(); 10 | private Optional image = Optional.empty(); 11 | 12 | public UserUpdate() { 13 | super(); 14 | } 15 | 16 | public UserUpdate( 17 | Optional email, 18 | Optional username, 19 | Optional password, 20 | Optional bio, 21 | Optional image) { 22 | this.email = email; 23 | this.username = username; 24 | this.password = password; 25 | this.bio = bio; 26 | this.image = image; 27 | } 28 | 29 | public Optional getEmail() { 30 | return email; 31 | } 32 | 33 | public Optional getUsername() { 34 | return username; 35 | } 36 | 37 | public Optional getPassword() { 38 | return password; 39 | } 40 | 41 | public Optional getBio() { 42 | return bio; 43 | } 44 | 45 | public Optional getImage() { 46 | return image; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | spring.application.name=realworld 2 | 3 | # Server 4 | server.port=${PORT} 5 | 6 | # Keycloak 7 | keycloak.realm=${KEYCLOAK_REALM} 8 | keycloak.admin-password=${KEYCLOAK_REALM_ADMIN_PASSWORD} 9 | keycloak.admin-username=${KEYCLOAK_REALM_ADMIN} 10 | keycloak.client-id=${KEYCLOAK_REALM_CLIENT_ID} 11 | keycloak.client-secret=${KEYCLOAK_REALM_CLIENT_SECRET} 12 | keycloak.server-url=${KEYCLOAK_SERVER_URL} 13 | 14 | # Security 15 | spring.security.oauth2.resourceserver.jwt.issuer-uri=${KEYCLOAK_SERVER_URL}/realms/${KEYCLOAK_REALM} 16 | spring.security.oauth2.resourceserver.jwt.jwk-set-uri=${spring.security.oauth2.resourceserver.jwt.issuer-uri}/protocol/openid-connect/certs 17 | 18 | # JWT 19 | jwt.auth.converter.resource-id=${KEYCLOAK_REALM_CLIENT_ID} 20 | jwt.auth.converter.principal-attribute=sub 21 | 22 | # Logging 23 | # logging.level.org.springframework.security=DEBUG 24 | 25 | # Datasource 26 | spring.datasource.url=jdbc:postgresql://${APP_DB_HOSTNAME}:${APP_DB_PORT}/${APP_DB} 27 | spring.datasource.username=${APP_DB_USERNAME} 28 | spring.datasource.password=${APP_DB_PASSWORD} 29 | spring.jpa.hibernate.ddl-auto=update -------------------------------------------------------------------------------- /src/test/java/com/marcusmonteirodesouza/realworld/RealworldApplicationTests.java: -------------------------------------------------------------------------------- 1 | package com.marcusmonteirodesouza.realworld; 2 | 3 | import org.junit.jupiter.api.Test; 4 | import org.springframework.boot.test.context.SpringBootTest; 5 | 6 | @SpringBootTest 7 | class RealworldApplicationTests { 8 | 9 | @Test 10 | void contextLoads() {} 11 | } 12 | --------------------------------------------------------------------------------