├── client ├── .env ├── .env.development ├── public │ ├── favicon.ico │ └── vercel.svg ├── pages │ ├── _app.js │ ├── api │ │ └── course │ │ │ └── index.js │ ├── index.js │ └── course.js ├── next.config.js ├── Dockerfile ├── package.json ├── .gitignore ├── README.md ├── azure-pipelines.yml └── styles │ └── globals.css ├── Dockerfile ├── .mvn └── wrapper │ ├── maven-wrapper.jar │ ├── maven-wrapper.properties │ └── MavenWrapperDownloader.java ├── README.md ├── src ├── main │ ├── java │ │ └── dev │ │ │ └── bluvolve │ │ │ └── reactive │ │ │ └── courseservice │ │ │ ├── course │ │ │ ├── ICategoryRepository.java │ │ │ ├── ICourseRepository.java │ │ │ ├── CategoryDto.java │ │ │ ├── events │ │ │ │ └── CourseCreated.java │ │ │ ├── commands │ │ │ │ └── CreateCourse.java │ │ │ ├── CourseDto.java │ │ │ ├── mappers │ │ │ │ ├── CategoryMapper.java │ │ │ │ └── CourseMapper.java │ │ │ ├── Category.java │ │ │ ├── processors │ │ │ │ └── CourseCreatedEventProcessor.java │ │ │ ├── CategoryService.java │ │ │ ├── Course.java │ │ │ ├── CourseService.java │ │ │ └── CourseController.java │ │ │ ├── CourseServiceApplication.java │ │ │ ├── config │ │ │ └── ApplicationConfig.java │ │ │ └── utils │ │ │ └── DataInitializer.java │ └── resources │ │ └── application.yaml └── test │ └── java │ └── dev │ └── bluvolve │ └── reactive │ └── courseservice │ └── CourseServiceApplicationTests.java ├── .gitignore ├── azure-pipelines.yml ├── pom.xml ├── mvnw.cmd ├── mvnw └── LICENSE /client/.env: -------------------------------------------------------------------------------- 1 | API_HOST=http://course-service.default -------------------------------------------------------------------------------- /client/.env.development: -------------------------------------------------------------------------------- 1 | API_HOST='http://localhost:4500' -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bluvolve-dev/reactive-course-service-with-nextjs-ui-/HEAD/client/public/favicon.ico -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM azul/zulu-openjdk-alpine:11 2 | COPY target/*.jar app.jar 3 | ENTRYPOINT exec java $JAVA_AGENT $JAVA_OPTS $JMX_OPTS -jar app.jar -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bluvolve-dev/reactive-course-service-with-nextjs-ui-/HEAD/.mvn/wrapper/maven-wrapper.jar -------------------------------------------------------------------------------- /client/pages/_app.js: -------------------------------------------------------------------------------- 1 | import '../styles/globals.css' 2 | 3 | function MyApp({ Component, pageProps }) { 4 | return 5 | } 6 | 7 | export default MyApp 8 | -------------------------------------------------------------------------------- /client/next.config.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config({ path: `./.env.${process.env.NODE_ENV}` }); 2 | 3 | module.exports = { 4 | publicRuntimeConfig: { 5 | API_HOST: process.env.API_HOST, 6 | }, 7 | } -------------------------------------------------------------------------------- /client/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14 2 | WORKDIR /usr/src/app 3 | 4 | COPY ./client/package.json ./ 5 | RUN npm install --production --loglevel warn 6 | 7 | COPY ./client/ ./ 8 | RUN yarn build 9 | 10 | EXPOSE 3000 11 | CMD ["yarn", "start"] -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.6.3/apache-maven-3.6.3-bin.zip 2 | wrapperUrl=https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # reactive-course-service 2 | Course Service - Reactive Spring Boot API with SSR NextJs UI 3 | 4 | For more information read my article on Medium.com 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/main/java/dev/bluvolve/reactive/courseservice/course/ICategoryRepository.java: -------------------------------------------------------------------------------- 1 | package dev.bluvolve.reactive.courseservice.course; 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository; 4 | 5 | import java.util.UUID; 6 | 7 | public interface ICategoryRepository extends JpaRepository { 8 | } 9 | -------------------------------------------------------------------------------- /src/main/resources/application.yaml: -------------------------------------------------------------------------------- 1 | server: 2 | port: 4500 3 | 4 | spring: 5 | datasource: 6 | url: jdbc:h2:mem:coursedb 7 | 8 | jpa: 9 | show-sql: true 10 | hibernate: 11 | dialect: org.hibernate.dialect.H2Dialect 12 | ddl-auto: update 13 | 14 | liquibase: 15 | enabled: false 16 | -------------------------------------------------------------------------------- /src/test/java/dev/bluvolve/reactive/courseservice/CourseServiceApplicationTests.java: -------------------------------------------------------------------------------- 1 | package dev.bluvolve.reactive.courseservice; 2 | 3 | import org.junit.jupiter.api.Test; 4 | import org.springframework.boot.test.context.SpringBootTest; 5 | 6 | @SpringBootTest 7 | class CourseServiceApplicationTests { 8 | 9 | @Test 10 | void contextLoads() { 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/dev/bluvolve/reactive/courseservice/course/ICourseRepository.java: -------------------------------------------------------------------------------- 1 | package dev.bluvolve.reactive.courseservice.course; 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository; 4 | 5 | import java.util.List; 6 | import java.util.UUID; 7 | 8 | public interface ICourseRepository extends JpaRepository { 9 | // add custom queries here 10 | List findCoursesByCategory(Category category); 11 | } 12 | -------------------------------------------------------------------------------- /src/main/java/dev/bluvolve/reactive/courseservice/course/CategoryDto.java: -------------------------------------------------------------------------------- 1 | package dev.bluvolve.reactive.courseservice.course; 2 | 3 | import lombok.Data; 4 | import lombok.NoArgsConstructor; 5 | import lombok.NonNull; 6 | 7 | import java.time.OffsetDateTime; 8 | import java.util.UUID; 9 | 10 | @Data 11 | @NoArgsConstructor 12 | public class CategoryDto { 13 | @NonNull 14 | private UUID id; 15 | @NonNull 16 | private String title; 17 | } 18 | -------------------------------------------------------------------------------- /src/main/java/dev/bluvolve/reactive/courseservice/course/events/CourseCreated.java: -------------------------------------------------------------------------------- 1 | package dev.bluvolve.reactive.courseservice.course.events; 2 | 3 | import dev.bluvolve.reactive.courseservice.course.Course; 4 | import lombok.Getter; 5 | import org.springframework.context.ApplicationEvent; 6 | 7 | @Getter 8 | public class CourseCreated extends ApplicationEvent { 9 | public CourseCreated(Course course) { 10 | super(course); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start" 9 | }, 10 | "dependencies": { 11 | "dotenv": "^8.2.0", 12 | "eventsource": "^1.0.7", 13 | "next": "9.5.3", 14 | "next-connect": "^0.9.0", 15 | "react": "16.13.1", 16 | "react-dom": "16.13.1", 17 | "uuid": "^8.3.1" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/dev/bluvolve/reactive/courseservice/CourseServiceApplication.java: -------------------------------------------------------------------------------- 1 | package dev.bluvolve.reactive.courseservice; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | 6 | @SpringBootApplication 7 | public class CourseServiceApplication { 8 | 9 | public static void main(String[] args) { 10 | SpringApplication.run(CourseServiceApplication.class, args); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/dev/bluvolve/reactive/courseservice/config/ApplicationConfig.java: -------------------------------------------------------------------------------- 1 | package dev.bluvolve.reactive.courseservice.config; 2 | 3 | import org.modelmapper.ModelMapper; 4 | import org.springframework.context.annotation.Bean; 5 | import org.springframework.context.annotation.Configuration; 6 | 7 | @Configuration 8 | public class ApplicationConfig { 9 | 10 | @Bean 11 | public ModelMapper modelMapper() { 12 | ModelMapper modelMapper = new ModelMapper(); 13 | return modelMapper; 14 | } 15 | } -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | -------------------------------------------------------------------------------- /src/main/java/dev/bluvolve/reactive/courseservice/course/commands/CreateCourse.java: -------------------------------------------------------------------------------- 1 | package dev.bluvolve.reactive.courseservice.course.commands; 2 | 3 | import lombok.Data; 4 | import lombok.NoArgsConstructor; 5 | import org.hibernate.validator.constraints.Range; 6 | 7 | import javax.validation.constraints.NotNull; 8 | import javax.validation.constraints.Size; 9 | import java.util.UUID; 10 | 11 | @Data 12 | @NoArgsConstructor 13 | public class CreateCourse { 14 | @NotNull 15 | @Size(min = 5, max = 25, message = "The title must be between 5 and 25 characters long.") 16 | private String title; 17 | 18 | @Size(max = 250, message = "The description must be a maximum of 250 characters long.") 19 | private String description; 20 | 21 | @NotNull 22 | private UUID categoryId; 23 | 24 | @NotNull 25 | private UUID createdByUserId; 26 | 27 | @NotNull 28 | @Range(min = 15L, max = 480L) 29 | private long duration = 45L; 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/dev/bluvolve/reactive/courseservice/course/CourseDto.java: -------------------------------------------------------------------------------- 1 | package dev.bluvolve.reactive.courseservice.course; 2 | 3 | import lombok.Data; 4 | import lombok.NoArgsConstructor; 5 | import lombok.NonNull; 6 | 7 | import java.time.OffsetDateTime; 8 | import java.util.UUID; 9 | 10 | @Data 11 | @NoArgsConstructor 12 | public class CourseDto { 13 | @NonNull 14 | private UUID id; 15 | @NonNull 16 | private String title; 17 | @NonNull 18 | private UUID categoryId; 19 | @NonNull 20 | private String categoryTitle; 21 | 22 | private UUID createdByUserId; 23 | 24 | private OffsetDateTime dateCreated; 25 | private OffsetDateTime lastUpdated; 26 | 27 | /* Gives a short description. */ 28 | private String teaser; 29 | 30 | /* A detailed description of course contents. */ 31 | private String description; 32 | 33 | /* The duration of the course in minutes. */ 34 | @NonNull 35 | private long duration; 36 | } 37 | -------------------------------------------------------------------------------- /src/main/java/dev/bluvolve/reactive/courseservice/course/mappers/CategoryMapper.java: -------------------------------------------------------------------------------- 1 | package dev.bluvolve.reactive.courseservice.course.mappers; 2 | 3 | import dev.bluvolve.reactive.courseservice.course.*; 4 | import dev.bluvolve.reactive.courseservice.course.commands.CreateCourse; 5 | import lombok.extern.slf4j.Slf4j; 6 | import org.modelmapper.ModelMapper; 7 | import org.springframework.stereotype.Component; 8 | 9 | @Component 10 | @Slf4j 11 | public class CategoryMapper { 12 | private final ModelMapper modelMapper;; 13 | 14 | public CategoryMapper(ModelMapper modelMapper) { 15 | this.modelMapper = modelMapper; 16 | } 17 | 18 | public CategoryDto entityToDto(Category category){ 19 | log.debug("Convert 'Category' entity to DTO. ['id': {}, 'title', {}]", 20 | category.getId(), category.getTitle()); 21 | 22 | CategoryDto dto = modelMapper.map(category, CategoryDto.class); 23 | 24 | log.debug("DTO '{}' initialized with id {}", dto.getTitle(), dto.getId()); 25 | return dto; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /client/public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | ``` 12 | 13 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 14 | 15 | You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file. 16 | 17 | ## Learn More 18 | 19 | To learn more about Next.js, take a look at the following resources: 20 | 21 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 22 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 23 | 24 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 25 | 26 | ## Deploy on Vercel 27 | 28 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/import?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 29 | 30 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 31 | -------------------------------------------------------------------------------- /src/main/java/dev/bluvolve/reactive/courseservice/course/Category.java: -------------------------------------------------------------------------------- 1 | package dev.bluvolve.reactive.courseservice.course; 2 | 3 | import lombok.Data; 4 | import lombok.NoArgsConstructor; 5 | 6 | import javax.persistence.*; 7 | import java.time.OffsetDateTime; 8 | import java.util.Set; 9 | import java.util.UUID; 10 | 11 | @Entity 12 | @Data 13 | @NoArgsConstructor 14 | public class Category { 15 | public Category(String title){ 16 | this.id = UUID.randomUUID(); 17 | this.title = title; 18 | } 19 | 20 | @Id 21 | @Column(nullable = false, updatable = false) 22 | private UUID id; 23 | 24 | @Column(nullable = false, unique = true, length = 50) 25 | private String title; 26 | 27 | @OneToMany(mappedBy = "category", targetEntity = Course.class, 28 | fetch = FetchType.LAZY) 29 | private Set courses; 30 | 31 | @Column(nullable = false, updatable = false) 32 | private OffsetDateTime dateCreated; 33 | 34 | @Column(nullable = false) 35 | private OffsetDateTime lastUpdated; 36 | 37 | @PrePersist 38 | public void prePersist() { 39 | this.dateCreated = OffsetDateTime.now(); 40 | this.lastUpdated = this.dateCreated; 41 | } 42 | 43 | @PreUpdate 44 | public void preUpdate() { 45 | this.lastUpdated = OffsetDateTime.now(); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/main/java/dev/bluvolve/reactive/courseservice/course/processors/CourseCreatedEventProcessor.java: -------------------------------------------------------------------------------- 1 | package dev.bluvolve.reactive.courseservice.course.processors; 2 | 3 | import dev.bluvolve.reactive.courseservice.course.events.CourseCreated; 4 | import lombok.extern.slf4j.Slf4j; 5 | import org.springframework.context.ApplicationListener; 6 | import org.springframework.stereotype.Component; 7 | import org.springframework.util.ReflectionUtils; 8 | import reactor.core.publisher.FluxSink; 9 | 10 | import java.util.concurrent.BlockingQueue; 11 | import java.util.concurrent.Executor; 12 | import java.util.concurrent.LinkedBlockingQueue; 13 | import java.util.function.Consumer; 14 | 15 | /** 16 | * Processes the 'CourseCreated' event. 17 | */ 18 | @Slf4j 19 | @Component 20 | public class CourseCreatedEventProcessor 21 | implements ApplicationListener, 22 | Consumer> { 23 | 24 | private final Executor executor; 25 | private final BlockingQueue queue = new LinkedBlockingQueue<>(); 26 | 27 | CourseCreatedEventProcessor(Executor executor) { 28 | this.executor = executor; 29 | } 30 | 31 | @Override 32 | public void onApplicationEvent(CourseCreated event) { 33 | this.queue.offer(event); 34 | } 35 | 36 | @Override 37 | public void accept(FluxSink sink) { 38 | this.executor.execute(() -> { 39 | while (true) 40 | try { 41 | CourseCreated event = queue.take(); 42 | sink.next(event); 43 | } 44 | catch (InterruptedException e) { 45 | ReflectionUtils.rethrowRuntimeException(e); 46 | } 47 | }); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /client/azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | name: $(Date:yyyyMMdd)$(Rev:.r) 2 | variables: 3 | - group: build 4 | jobs: 5 | - job: Build 6 | pool: 7 | vmImage: ubuntu-16.04 8 | steps: 9 | - checkout: self 10 | clean: true 11 | persistCredentials: true 12 | - bash: | 13 | echo "pipeline variable 'app.registry.url' was not set but is required." 14 | exit 1 15 | condition: eq(variables['app.registry.url'], '') 16 | name: ValidateAppRegistryUrl 17 | - bash: | 18 | echo "pipeline variable 'app.registry.username' was not set but is required." 19 | exit 1 20 | condition: eq(variables['app.registry.username'], '') 21 | name: ValidateAppRegistryUsername 22 | - bash: | 23 | echo "pipeline variable 'app.registry.password' was not set but is required." 24 | exit 1 25 | condition: eq(variables['app.registry.password'], '') 26 | name: ValidateAppRegistryPassword 27 | - bash: | 28 | BUILD_NUMBER=$(Build.BuildId)-$(git rev-parse --short ${BUILD_SOURCEVERSION}) 29 | echo "##vso[build.updatebuildnumber]${BUILD_NUMBER}" 30 | name: Version 31 | - bash: | 32 | docker login ${APP_REGISTRY_URL} -u ${APP_REGISTRY_USERNAME} -p ${APP_REGISTRY_PASSWORD} 33 | name: DockerLogin 34 | env: 35 | APP_REGISTRY_PASSWORD: $(app.registry.password) 36 | - bash: | 37 | docker build ${BUILD_SOURCESDIRECTORY} \ 38 | -f ./client/Dockerfile \ 39 | -t ${APP_REGISTRY_URL}/course-service-client:${BUILD_BUILDNUMBER} \ 40 | -t latest 41 | name: DockerBuild 42 | - bash: | 43 | docker push ${APP_REGISTRY_URL}/course-service-client:${BUILD_BUILDNUMBER} 44 | name: DockerPush -------------------------------------------------------------------------------- /src/main/java/dev/bluvolve/reactive/courseservice/course/CategoryService.java: -------------------------------------------------------------------------------- 1 | package dev.bluvolve.reactive.courseservice.course; 2 | 3 | import dev.bluvolve.reactive.courseservice.course.mappers.CategoryMapper; 4 | import lombok.extern.slf4j.Slf4j; 5 | import org.springframework.stereotype.Service; 6 | import org.springframework.util.Assert; 7 | 8 | import java.util.List; 9 | import java.util.UUID; 10 | import java.util.stream.Collectors; 11 | 12 | @Service 13 | @Slf4j 14 | public class CategoryService { 15 | 16 | private final ICategoryRepository repository; 17 | private final CategoryMapper mapper; 18 | 19 | public CategoryService(ICategoryRepository repository, CategoryMapper mapper) { 20 | this.repository = repository; 21 | this.mapper = mapper; 22 | } 23 | 24 | /** 25 | * Returns the instance of a category by given id. 26 | * @param id the given id. 27 | * @return category instance. 28 | */ 29 | public Category getById(UUID id){ 30 | Assert.notNull(id, "The given id must not be null!"); 31 | 32 | log.debug("Try to get Category with id {}", id); 33 | Category category = this.repository.getOne(id); 34 | 35 | if(category == null){ 36 | log.error("Category with id {} does not exist.", id); 37 | return null; 38 | } 39 | 40 | log.debug("Category {} with id {} was found.", category.getTitle(), id); 41 | return category; 42 | } 43 | 44 | /** 45 | * Fetches all categories. 46 | * @return a list of categories. 47 | */ 48 | public List getCategories() { 49 | return this.repository.findAll() 50 | .stream().map(c -> this.mapper.entityToDto(c)) 51 | .collect(Collectors.toList()); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /client/styles/globals.css: -------------------------------------------------------------------------------- 1 | html { 2 | font-family: Ubuntu,sans-serif; 3 | } 4 | 5 | body { 6 | background-color: #f9f9f9; 7 | font-family: Ubuntu,sans-serif; 8 | transition: all .25s ease-in-out; 9 | position: relative; 10 | left: 0; 11 | margin: 0; 12 | display: block; 13 | } 14 | 15 | div{ 16 | margin: 6px; 17 | } 18 | 19 | th { 20 | padding: 12px; 21 | text-align: left; 22 | background-color: #54555a; 23 | color: white; 24 | } 25 | 26 | tr:hover { 27 | background-color: #f5f5f5; 28 | } 29 | 30 | td { 31 | border: 1px; 32 | padding: 8px; 33 | } 34 | 35 | table { 36 | margin-top: 12px; 37 | border-collapse: collapse; 38 | width: 100%; 39 | text-decoration: none; 40 | font-family: "Trebuchet MS", Arial, Helvetica, sans-serif; 41 | } 42 | 43 | a { 44 | color: white; 45 | text-decoration: none; 46 | } 47 | 48 | button { 49 | transition-duration: 0.4s; 50 | border: none; 51 | color: white; 52 | text-align: center; 53 | text-decoration: none; 54 | display: inline-block; 55 | border-radius: 2px; 56 | font-size: 12px; 57 | background-color: #277ebf; 58 | padding: 12px 28px; 59 | margin-bottom: 6px; 60 | } 61 | 62 | button:hover { 63 | background-color: #3d83b8; 64 | color: white; 65 | } 66 | 67 | form{ 68 | padding-top: 24px; 69 | } 70 | 71 | input[type=text], select, textarea { 72 | width: 100%; 73 | padding: 12px 20px; 74 | margin: 8px 0; 75 | display: inline-block; 76 | border: 1px solid #ccc; 77 | border-radius: 4px; 78 | box-sizing: border-box; 79 | } 80 | 81 | input[type=submit] { 82 | width: 100%; 83 | background-color: #277ebf; 84 | color: white; 85 | padding: 14px 20px; 86 | margin: 8px 0; 87 | border: none; 88 | border-radius: 4px; 89 | cursor: pointer; 90 | } 91 | 92 | input[type=submit]:hover { 93 | background-color: #3d83b8; 94 | } 95 | 96 | * { 97 | box-sizing: border-box; 98 | } 99 | -------------------------------------------------------------------------------- /src/main/java/dev/bluvolve/reactive/courseservice/course/Course.java: -------------------------------------------------------------------------------- 1 | package dev.bluvolve.reactive.courseservice.course; 2 | 3 | import lombok.Builder; 4 | import lombok.Data; 5 | import lombok.NoArgsConstructor; 6 | 7 | import javax.persistence.*; 8 | import java.time.Duration; 9 | import java.time.OffsetDateTime; 10 | import java.util.UUID; 11 | 12 | @Entity 13 | @Data 14 | @NoArgsConstructor 15 | public class Course { 16 | public Course(String title, Category category, UUID createdByUserId, Long duration) { 17 | this.id = UUID.randomUUID(); 18 | this.title = title; 19 | this.category = category; 20 | this.createdByUserId = createdByUserId; 21 | this.duration = duration; 22 | } 23 | 24 | @Id 25 | @Column(nullable = false, updatable = false) 26 | private UUID id; 27 | 28 | @Column(nullable = false, length = 25) 29 | private String title; 30 | 31 | @ManyToOne 32 | @JoinColumn(name = "category_id", nullable = false) 33 | private Category category; 34 | 35 | @Column( nullable = false, updatable = false) 36 | private UUID createdByUserId; 37 | 38 | @Column(nullable = false, updatable = false) 39 | private OffsetDateTime dateCreated; 40 | 41 | @Column(nullable = false) 42 | private OffsetDateTime lastUpdated; 43 | 44 | /* Gives a short description. */ 45 | @Column() 46 | private String teaser; 47 | 48 | /* A detailed description of course contents. */ 49 | @Column() 50 | private String description; 51 | 52 | /* The duration of the course. */ 53 | @Column(nullable = false) 54 | private long duration = Duration.ofMinutes(45L).toMinutes(); 55 | 56 | @PrePersist 57 | public void prePersist() { 58 | this.dateCreated = OffsetDateTime.now(); 59 | this.lastUpdated = this.dateCreated; 60 | } 61 | 62 | @PreUpdate 63 | public void preUpdate() { 64 | 65 | this.lastUpdated = OffsetDateTime.now(); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/main/java/dev/bluvolve/reactive/courseservice/course/mappers/CourseMapper.java: -------------------------------------------------------------------------------- 1 | package dev.bluvolve.reactive.courseservice.course.mappers; 2 | 3 | import dev.bluvolve.reactive.courseservice.course.Category; 4 | import dev.bluvolve.reactive.courseservice.course.CategoryService; 5 | import dev.bluvolve.reactive.courseservice.course.Course; 6 | import dev.bluvolve.reactive.courseservice.course.CourseDto; 7 | import dev.bluvolve.reactive.courseservice.course.commands.CreateCourse; 8 | import lombok.extern.slf4j.Slf4j; 9 | import org.modelmapper.ModelMapper; 10 | import org.springframework.stereotype.Component; 11 | 12 | import java.util.UUID; 13 | 14 | @Component 15 | @Slf4j 16 | public class CourseMapper { 17 | private final ModelMapper modelMapper; 18 | private final CategoryService categoryService; 19 | 20 | public CourseMapper(ModelMapper modelMapper, CategoryService categoryService) { 21 | this.modelMapper = modelMapper; 22 | this.categoryService = categoryService; 23 | } 24 | 25 | public Course commandToEntity(CreateCourse command){ 26 | log.debug("Convert 'CreateCourse' command to new course instance. ['userId': {}, 'title', {}]", 27 | command.getCreatedByUserId(), command.getTitle()); 28 | 29 | Category category = this.categoryService.getById(command.getCategoryId()); 30 | 31 | Course course = modelMapper.map(command, Course.class); 32 | course.setId(UUID.randomUUID()); 33 | course.setCategory(category); 34 | 35 | log.debug("Course entity {} with id {} initialized.", course.getTitle(), course.getId()); 36 | return course; 37 | } 38 | 39 | public CourseDto entityToDto(Course course){ 40 | log.debug("Convert 'Course' entity to DTO. ['id': {}, 'title', {}]", 41 | course.getId(), course.getTitle()); 42 | 43 | CourseDto dto = modelMapper.map(course, CourseDto.class); 44 | 45 | log.debug("DTO '{}' initialized with id {}", course.getTitle(), course.getId()); 46 | return dto; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /client/pages/api/course/index.js: -------------------------------------------------------------------------------- 1 | import nextConnect from 'next-connect'; 2 | import EventSource from 'eventsource'; 3 | 4 | const API_HOST = process.env.API_HOST; 5 | 6 | const handler = nextConnect(); 7 | 8 | const sseMiddleware = (req, res, next) => { 9 | res.setHeader('Content-Type', 'text/event-stream'); 10 | res.setHeader('Cache-Control', 'no-cache'); 11 | res.flushHeaders(); 12 | 13 | const flushData = (data) => { 14 | const sseFormattedResponse = `data: ${data}\n\n`; 15 | res.write("event: message\n"); 16 | res.write(sseFormattedResponse); 17 | res.flush(); 18 | } 19 | 20 | Object.assign(res, { 21 | flushData 22 | }); 23 | 24 | next(); 25 | } 26 | 27 | const stream = async (req, res) => { 28 | console.log("connect to sse stream"); 29 | 30 | let eventSource = new EventSource(`${API_HOST}/course/sse`); 31 | eventSource.onopen = (e) => { console.log('listen to sse endpoint now', e)}; 32 | eventSource.onmessage = (e) => { 33 | res.flushData(e.data); 34 | }; 35 | eventSource.onerror = (e) => { console.log('error', e )}; 36 | 37 | // close connection (detach subscriber) 38 | res.on('close', () => { 39 | console.log("close connection..."); 40 | eventSource.close(); 41 | eventSource = null; 42 | res.end(); 43 | }); 44 | } 45 | 46 | // Stream API Data 47 | handler.get(sseMiddleware, stream); 48 | 49 | handler.post(async (req, res) => { 50 | console.log("api call"); 51 | 52 | const course = req.body.course; 53 | 54 | await fetch(`${API_HOST}/course`, { 55 | method: 'POST', 56 | headers: { 57 | 'Accept': 'application/json', 58 | 'Content-Type': 'application/json' 59 | }, 60 | body: JSON.stringify(course), 61 | }).then(r => { 62 | if(r.status === 201){ 63 | res.json({ message: "ok" }); 64 | }else{ 65 | res.status(r.status).json({status: r.status, message: r.statusText}) 66 | } 67 | }); 68 | }); 69 | 70 | export default handler; -------------------------------------------------------------------------------- /client/pages/index.js: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useState} from 'react'; 2 | import Head from 'next/head'; 3 | import Link from 'next/link'; 4 | 5 | import getConfig from 'next/config'; 6 | const { publicRuntimeConfig } = getConfig(); 7 | const { API_HOST } = publicRuntimeConfig; 8 | 9 | const Index = ({ initialCourses }) => { 10 | const [ courses, setCourses ] = useState(initialCourses); 11 | 12 | useEffect(() => { 13 | let eventSource = new EventSource(`/api/course`); 14 | eventSource.onopen = (e) => { console.log('listen to api-sse endpoint', e)}; 15 | 16 | eventSource.onmessage = (e) => { 17 | const course = JSON.parse(e.data); 18 | 19 | if (!courses.includes(course)){ 20 | setCourses( courses => [...courses, course]); 21 | } 22 | }; 23 | 24 | eventSource.onerror = (e) => { console.log('error', e )}; 25 | 26 | // returned function will be called on component unmount 27 | return () => { 28 | eventSource.close(); 29 | eventSource = null; 30 | } 31 | },[]) 32 | 33 | const courseList = courses.map(course => { 34 | return 35 | {course.title} 36 | {course.categoryTitle} 37 | {course.duration} 38 | 39 | }); 40 | 41 | return ( 42 |
43 | 44 | Course Service Example 45 | 46 | 47 | 48 |
49 |

Courses

50 | 51 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | {courseList} 66 | 67 |
TitleCategoryDuration (minutes)
68 |
69 |
70 | ) 71 | } 72 | 73 | export const getServerSideProps = async () => { 74 | const res = await fetch(`${API_HOST}/course`); 75 | const data = await res.json(); 76 | return { props: { 77 | initialCourses: data 78 | } 79 | } 80 | }; 81 | 82 | export default Index; -------------------------------------------------------------------------------- /src/main/java/dev/bluvolve/reactive/courseservice/course/CourseService.java: -------------------------------------------------------------------------------- 1 | package dev.bluvolve.reactive.courseservice.course; 2 | 3 | 4 | import dev.bluvolve.reactive.courseservice.course.commands.CreateCourse; 5 | import dev.bluvolve.reactive.courseservice.course.events.CourseCreated; 6 | import dev.bluvolve.reactive.courseservice.course.mappers.CourseMapper; 7 | import lombok.extern.slf4j.Slf4j; 8 | import org.springframework.beans.factory.annotation.Autowired; 9 | import org.springframework.context.ApplicationEventPublisher; 10 | import org.springframework.stereotype.Service; 11 | import org.springframework.util.Assert; 12 | 13 | import java.util.List; 14 | import java.util.stream.Collectors; 15 | 16 | @Slf4j 17 | @Service 18 | public class CourseService { 19 | 20 | private final ICourseRepository courseRepository; 21 | 22 | private final ApplicationEventPublisher publisher; 23 | 24 | private final CourseMapper mapper; 25 | 26 | @Autowired 27 | public CourseService(ICourseRepository courseRepository, ApplicationEventPublisher publisher, CourseMapper mapper) { 28 | this.courseRepository = courseRepository; 29 | this.publisher = publisher; 30 | this.mapper = mapper; 31 | } 32 | 33 | /** 34 | * Creates a new course from the given 'CreateCourse' command. 35 | * @param command the command. 36 | * @return an instance of the saved course. 37 | */ 38 | public Course createCourse(CreateCourse command) { 39 | Assert.notNull(command, "The given command must not be null!"); 40 | 41 | log.debug("Try to create new course {} requested by {}.", command.getTitle(), command.getCreatedByUserId()); 42 | 43 | Course course = this.mapper.commandToEntity(command); 44 | 45 | Course savedCourse = this.courseRepository.save(course); 46 | log.debug("Course {} saved to database. Created timestamp {}", savedCourse.getId(), savedCourse.getDateCreated()); 47 | 48 | this.publisher.publishEvent(new CourseCreated(savedCourse)); 49 | 50 | return savedCourse; 51 | } 52 | 53 | /** 54 | * Fetches all courses. 55 | * @return a list of courses. 56 | */ 57 | public List getCourses() { 58 | return this.courseRepository.findAll() 59 | .stream().map(c -> this.mapper.entityToDto(c)) 60 | .collect(Collectors.toList()); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | name: $(Date:yyyyMMdd)$(Rev:.r) 2 | 3 | variables: 4 | - group: build 5 | jobs: 6 | - job: Build 7 | pool: 8 | vmImage: ubuntu-16.04 9 | steps: 10 | - checkout: self 11 | clean: true 12 | persistCredentials: true 13 | - bash: | 14 | echo "pipeline variable 'app.registry.url' was not set but is required." 15 | exit 1 16 | condition: eq(variables['app.registry.url'], '') 17 | name: ValidateAppRegistryUrl 18 | - bash: | 19 | echo "pipeline variable 'app.registry.username' was not set but is required." 20 | exit 1 21 | condition: eq(variables['app.registry.username'], '') 22 | name: ValidateAppRegistryUsername 23 | - bash: | 24 | echo "pipeline variable 'app.registry.password' was not set but is required." 25 | exit 1 26 | condition: eq(variables['app.registry.password'], '') 27 | name: ValidateAppRegistryPassword 28 | - task: Maven@3 29 | inputs: 30 | mavenPomFile: 'pom.xml' 31 | publishJUnitResults: false 32 | javaHomeOption: 'JDKVersion' 33 | jdkVersionOption: '1.11' 34 | mavenVersionOption: 'Default' 35 | mavenAuthenticateFeed: false 36 | effectivePomSkip: false 37 | sonarQubeRunAnalysis: false 38 | sqMavenPluginVersionChoice: 'latest' 39 | findBugsRunAnalysis: false 40 | - bash: | 41 | MAVEN_VERSION=$(mvn org.apache.maven.plugins:maven-help-plugin:3.1.0:evaluate -q -Dexpression=project.version -DforceStdout) 42 | BUILD_NUMBER=${MAVEN_VERSION}-$(git rev-parse --short ${BUILD_SOURCEVERSION}) 43 | echo "##vso[build.updatebuildnumber]${BUILD_NUMBER}" 44 | name: Version 45 | - bash: | 46 | docker login ${APP_REGISTRY_URL} -u ${APP_REGISTRY_USERNAME} -p ${APP_REGISTRY_PASSWORD} 47 | name: DockerLogin 48 | env: 49 | APP_REGISTRY_PASSWORD: $(app.registry.password) 50 | - bash: | 51 | docker build ${BUILD_SOURCESDIRECTORY} \ 52 | -t ${APP_REGISTRY_URL}/course-service:${BUILD_BUILDNUMBER} \ 53 | -t latest 54 | name: DockerBuild 55 | - bash: | 56 | docker push ${APP_REGISTRY_URL}/course-service:${BUILD_BUILDNUMBER} 57 | name: DockerPush 58 | - bash: | 59 | mkdir ${BUILD_SOURCESDIRECTORY}/output 60 | cp ${BUILD_SOURCESDIRECTORY}/target/*.jar ${BUILD_SOURCESDIRECTORY}/output 61 | name: Output 62 | - task: PublishTestResults@2 63 | name: PublishTests 64 | - publish: $(Build.SourcesDirectory)/output 65 | artifact: output 66 | name: PublishOutput 67 | 68 | -------------------------------------------------------------------------------- /src/main/java/dev/bluvolve/reactive/courseservice/utils/DataInitializer.java: -------------------------------------------------------------------------------- 1 | package dev.bluvolve.reactive.courseservice.utils; 2 | 3 | import dev.bluvolve.reactive.courseservice.course.Category; 4 | import dev.bluvolve.reactive.courseservice.course.Course; 5 | import dev.bluvolve.reactive.courseservice.course.ICategoryRepository; 6 | import dev.bluvolve.reactive.courseservice.course.ICourseRepository; 7 | import lombok.extern.slf4j.Slf4j; 8 | import org.springframework.boot.context.event.ApplicationReadyEvent; 9 | import org.springframework.context.ApplicationListener; 10 | import org.springframework.stereotype.Component; 11 | 12 | import java.util.ArrayList; 13 | import java.util.List; 14 | import java.util.UUID; 15 | 16 | @Component 17 | @Slf4j 18 | public class DataInitializer implements ApplicationListener { 19 | 20 | private final ICategoryRepository repository; 21 | private final ICourseRepository courseRepository; 22 | 23 | public DataInitializer(ICategoryRepository repository, ICourseRepository courseRepository) { 24 | this.courseRepository = courseRepository; 25 | log.info("Run data initializer..."); 26 | this.repository = repository; 27 | } 28 | 29 | @Override 30 | public void onApplicationEvent(ApplicationReadyEvent applicationReadyEvent) { 31 | if (this.repository.count() > 0){ 32 | log.info("Category items already created."); 33 | return; 34 | } 35 | 36 | List categories = new ArrayList<>() { 37 | { 38 | add(new Category("Bootcamp")); 39 | add(new Category("Circuit Training")); 40 | add(new Category("Gymnastics")); 41 | add(new Category("Outdoor")); 42 | add(new Category("Weight Training")); 43 | } 44 | }; 45 | 46 | categories.forEach(category -> { 47 | this.repository.save(category); 48 | log.info("Category '{}' saved. ID: {}", category.getTitle(), category.getId()); 49 | }); 50 | 51 | this.createExampleCourses(categories); 52 | } 53 | 54 | private void createExampleCourses(List categories){ 55 | List courses = new ArrayList<>() { 56 | { 57 | add(new Course("Outdoor Bootcamp", categories.get(0), UUID.randomUUID(), 60L)); 58 | add(new Course("Hurricane Bootcamp", categories.get(0), UUID.randomUUID(), 45L)); 59 | add(new Course("Six Pack Workout", categories.get(3), UUID.randomUUID(), 45L)); 60 | add(new Course("XXL Legs Workout", categories.get(4), UUID.randomUUID(), 90L)); 61 | } 62 | }; 63 | 64 | this.courseRepository.saveAll(courses); 65 | log.debug("Sample courses created."); 66 | } 67 | } -------------------------------------------------------------------------------- /client/pages/course.js: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react'; 2 | import Link from 'next/link'; 3 | import { v4 as uuid } from 'uuid'; 4 | 5 | import getConfig from 'next/config'; 6 | const { publicRuntimeConfig } = getConfig(); 7 | const { API_HOST } = publicRuntimeConfig; 8 | 9 | const NewCourse = ({ categories, userId }) => { 10 | const emptyCourse = { 11 | title: null, 12 | description: null, 13 | categoryId: categories[0].id, 14 | createdByUserId: userId, 15 | duration: 60 16 | }; 17 | 18 | const [ course, setCourse ] = useState(emptyCourse); 19 | const [ saved, setSaved ] = useState(false); 20 | const [ error, setError ] = useState(null); 21 | 22 | const handleSubmit = async (e) => { 23 | e.preventDefault(); 24 | await fetch('/api/course', { 25 | method: 'POST', 26 | headers: { 27 | 'Accept': 'application/json', 28 | 'Content-Type': 'application/json' 29 | }, 30 | body: JSON.stringify({ 31 | course: course, 32 | }), 33 | }).then(res => { 34 | if(res.status === 200){ 35 | setSaved(true); 36 | }else{ 37 | setError('Error: ' + res.status + ' :: ' + res.statusText); 38 | } 39 | }); 40 | } 41 | 42 | const handleChange = (e) => { 43 | e.preventDefault(); 44 | 45 | const { name, value } = e.target; 46 | setCourse(prevState => ({ 47 | ...prevState, 48 | [name]: value 49 | })); 50 | } 51 | 52 | const categoryOptions = categories.map(c => { 53 | return 54 | }); 55 | 56 | return
57 |

Create New Course

58 | 59 | 62 | 63 | { error &&

{error}

} 64 | { saved &&

Congrats! The course '{course.title}' was saved successfully.

} 65 | { !saved &&
66 | 67 | 69 | 70 | 71 | 73 | 74 | 75 | 78 | 79 | 80 |