├── .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 | # 
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 | 
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 | 
35 | 
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 | 
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 | 
9 | 
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 | 
12 | 
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 | 
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 | 
17 | 
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