diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..566e291 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,16 @@ +name: Build + +on: [push, pull_request] + +jobs: + build_java11: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - name: Install JDK 11 + uses: joschi/setup-jdk@v1.0.0 + with: + java-version: 'openjdk11' + - name: Build with Maven + run: mvn -B clean install -Pnative -Dquarkus.native.container-build=true + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5783038 --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# Created by .ignore support plugin (hsz.mobi) +### Java template +# Compiled class file +*.class + +# Log file +*.log + +#IntelliJ files +.idea/ +*.iml + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +### Maven template +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties + +# Avoid ignoring Maven wrapper jar file (.jar files are usually ignored) +!/.mvn/wrapper/maven-wrapper.jar + +.project +.classpath +.settings +.java-version diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/barista-http/pom.xml b/barista-http/pom.xml new file mode 100644 index 0000000..08b487c --- /dev/null +++ b/barista-http/pom.xml @@ -0,0 +1,107 @@ + + + 4.0.0 + + + me.escoffier.quarkus.reactive + quarkus-coffeeshop-demo + 1.0-SNAPSHOT + .. + + + barista-http + + + + io.quarkus + quarkus-resteasy + + + io.quarkus + quarkus-resteasy-jsonb + + + io.quarkus + quarkus-jsonp + + + io.quarkus + quarkus-smallrye-health + + + + + + + io.quarkus + quarkus-maven-plugin + ${quarkus.version} + + + + build + + + + + + maven-surefire-plugin + ${surefire-plugin.version} + + + org.jboss.logmanager.LogManager + + + + + + + + native + + + native + + + + + + io.quarkus + quarkus-maven-plugin + ${quarkus.version} + + + + native-image + + + true + + + + + + maven-failsafe-plugin + ${surefire-plugin.version} + + + + integration-test + verify + + + + ${project.build.directory}/${project.build.finalName}-runner + + + + + + + + + + + \ No newline at end of file diff --git a/barista-http/src/main/docker/Dockerfile.native b/barista-http/src/main/docker/Dockerfile.native new file mode 100644 index 0000000..8079acf --- /dev/null +++ b/barista-http/src/main/docker/Dockerfile.native @@ -0,0 +1,22 @@ +#### +# This Dockerfile is used in order to build a container that runs the Quarkus application in native (no JVM) mode +# +# Before building the docker image run: +# +# mvn package -Pnative -Dquarkus.native.container-build=true +# +# Then, build the image with: +# +# docker build -f src/main/docker/Dockerfile.native -t coffee/barista-http . +# +# Then run the container using: +# +# docker run -i --rm -p 8080:8080 coffee/barista-http +# +### +FROM registry.access.redhat.com/ubi8/ubi-minimal:8.1 +WORKDIR /work/ +COPY target/*-runner /work/application +RUN chmod 775 /work /work/application +EXPOSE 8080 +CMD ["./application", "-Dquarkus.http.host=0.0.0.0"] \ No newline at end of file diff --git a/barista-http/src/main/java/me/escoffier/quarkus/coffeeshop/BaristaResource.java b/barista-http/src/main/java/me/escoffier/quarkus/coffeeshop/BaristaResource.java new file mode 100644 index 0000000..de0bca8 --- /dev/null +++ b/barista-http/src/main/java/me/escoffier/quarkus/coffeeshop/BaristaResource.java @@ -0,0 +1,52 @@ +package me.escoffier.quarkus.coffeeshop; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.MediaType; +import java.util.Random; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static me.escoffier.quarkus.coffeeshop.Names.pickAName; + +@Path("/barista") +@Produces(MediaType.APPLICATION_JSON) +public class BaristaResource { + + private static final Logger LOGGER = LoggerFactory.getLogger("HTTP-Barista"); + + private ExecutorService queue = Executors.newSingleThreadExecutor(); + + private String name = pickAName(); + + @POST + public CompletionStage process(Order order) { + return CompletableFuture.supplyAsync(() -> { + Beverage coffee = prepare(order); + LOGGER.info("Order {} for {} is ready", order.getProduct(), order.getName()); + return coffee; + }, queue); + } + + Beverage prepare(Order order) { + int delay = getPreparationTime(); + try { + Thread.sleep(delay); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return new Beverage(order, name); + } + + private Random random = new Random(); + int getPreparationTime() { + return random.nextInt(5) * 1000; + } + +} diff --git a/barista-http/src/main/java/me/escoffier/quarkus/coffeeshop/Beverage.java b/barista-http/src/main/java/me/escoffier/quarkus/coffeeshop/Beverage.java new file mode 100644 index 0000000..3a026dd --- /dev/null +++ b/barista-http/src/main/java/me/escoffier/quarkus/coffeeshop/Beverage.java @@ -0,0 +1,56 @@ +package me.escoffier.quarkus.coffeeshop; + +public class Beverage { + + private String beverage; + private String customer; + private String preparedBy; + private String orderId; + + public Beverage() { + + } + + public Beverage(Order order, String baristaName) { + this.beverage = order.getProduct(); + this.customer = order.getName(); + this.orderId = order.getOrderId(); + this.preparedBy = baristaName; + } + + public String getBeverage() { + return beverage; + } + + public Beverage setBeverage(String beverage) { + this.beverage = beverage; + return this; + } + + public String getCustomer() { + return customer; + } + + public Beverage setCustomer(String customer) { + this.customer = customer; + return this; + } + + public String getPreparedBy() { + return preparedBy; + } + + public Beverage setPreparedBy(String preparedBy) { + this.preparedBy = preparedBy; + return this; + } + + public String getOrderId() { + return orderId; + } + + public Beverage setOrderId(String orderId) { + this.orderId = orderId; + return this; + } +} diff --git a/barista-http/src/main/java/me/escoffier/quarkus/coffeeshop/Names.java b/barista-http/src/main/java/me/escoffier/quarkus/coffeeshop/Names.java new file mode 100644 index 0000000..28a9744 --- /dev/null +++ b/barista-http/src/main/java/me/escoffier/quarkus/coffeeshop/Names.java @@ -0,0 +1,46 @@ +package me.escoffier.quarkus.coffeeshop; + +import java.util.Arrays; +import java.util.List; +import java.util.Random; + +class Names { + + private static final List VALUES = Arrays.asList( + "Olivia", + "Oliver", + "Amelia", + "George", + "Isla", + "Harry", + "Ava", + "Noah", + "Emily", + "Jack", + "Sophia", + "Charlie", + "Grace", + "Leo", + "Mia", + "Jacob", + "Poppy", + "Freddie", + "Ella", + "Alfie", + "Tom", + "Julie", + "Matt", + "Joe", + "Zoe" + ); + + static String pickAName() { + Random random = new Random(); //NOSONAR - cannot be a static field, would be inlined at build time. + int index = random.nextInt(VALUES.size()); + return VALUES.get(index); + } + + private Names() { + // avoid direct instantiation + } +} diff --git a/barista-http/src/main/java/me/escoffier/quarkus/coffeeshop/Order.java b/barista-http/src/main/java/me/escoffier/quarkus/coffeeshop/Order.java new file mode 100644 index 0000000..ba9d2f2 --- /dev/null +++ b/barista-http/src/main/java/me/escoffier/quarkus/coffeeshop/Order.java @@ -0,0 +1,35 @@ +package me.escoffier.quarkus.coffeeshop; + +public class Order { + + private String product; + private String name; + private String orderId; + + public String getProduct() { + return product; + } + + public Order setProduct(String product) { + this.product = product; + return this; + } + + public String getName() { + return name; + } + + public Order setName(String name) { + this.name = name; + return this; + } + + public String getOrderId() { + return orderId; + } + + public Order setOrderId(String orderId) { + this.orderId = orderId; + return this; + } +} diff --git a/barista-http/src/main/resources/application.properties b/barista-http/src/main/resources/application.properties new file mode 100644 index 0000000..ff9f06a --- /dev/null +++ b/barista-http/src/main/resources/application.properties @@ -0,0 +1 @@ +quarkus.http.port=8081 \ No newline at end of file diff --git a/barista-kafka/another-kafka-barista.sh b/barista-kafka/another-kafka-barista.sh new file mode 100755 index 0000000..349ad3d --- /dev/null +++ b/barista-kafka/another-kafka-barista.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +./target/barista-kafka-1.0-SNAPSHOT-runner -Dquarkus.http.port=9999 diff --git a/barista-kafka/pom.xml b/barista-kafka/pom.xml new file mode 100644 index 0000000..b5b51c2 --- /dev/null +++ b/barista-kafka/pom.xml @@ -0,0 +1,139 @@ + + + 4.0.0 + + me.escoffier.quarkus.reactive + quarkus-coffeeshop-demo + 1.0-SNAPSHOT + .. + + + barista-kafka + + + + io.quarkus + quarkus-vertx + + + io.quarkus + quarkus-resteasy + + + io.quarkus + quarkus-resteasy-mutiny + + + io.quarkus + quarkus-resteasy-jsonb + + + io.quarkus + quarkus-jsonp + + + io.quarkus + quarkus-smallrye-reactive-messaging + + + io.quarkus + quarkus-smallrye-reactive-messaging-kafka + + + io.quarkus + quarkus-smallrye-health + + + io.quarkus + quarkus-junit5 + test + + + io.smallrye.reactive + smallrye-reactive-messaging-in-memory + 2.0.2 + test + + + org.awaitility + awaitility + 4.0.2 + test + + + + + + + io.quarkus + quarkus-maven-plugin + ${quarkus.version} + + + + build + + + + + + maven-surefire-plugin + ${surefire-plugin.version} + + + org.jboss.logmanager.LogManager + + + + + + + + native + + + native + + + + + + io.quarkus + quarkus-maven-plugin + ${quarkus.version} + + + + native-image + + + true + + + + + + maven-failsafe-plugin + ${surefire-plugin.version} + + + + integration-test + verify + + + + ${project.build.directory}/${project.build.finalName}-runner + + + + + + + + + + + diff --git a/barista-kafka/src/main/docker/Dockerfile.native b/barista-kafka/src/main/docker/Dockerfile.native new file mode 100644 index 0000000..dc50c92 --- /dev/null +++ b/barista-kafka/src/main/docker/Dockerfile.native @@ -0,0 +1,22 @@ +#### +# This Dockerfile is used in order to build a container that runs the Quarkus application in native (no JVM) mode +# +# Before building the docker image run: +# +# mvn package -Pnative -Dquarkus.native.container-build=true +# +# Then, build the image with: +# +# docker build -f src/main/docker/Dockerfile.native -t coffee/barista-kafka . +# +# Then run the container using: +# +# docker run -i --rm -p 8080:8080 coffee/barista-kafka +# +### +FROM registry.access.redhat.com/ubi8/ubi-minimal:8.1 +WORKDIR /work/ +COPY target/*-runner /work/application +RUN chmod 775 /work /work/application +EXPOSE 8080 +CMD ["./application", "-Dquarkus.http.host=0.0.0.0"] \ No newline at end of file diff --git a/barista-kafka/src/main/java/me/escoffier/quarkus/coffeeshop/Beverage.java b/barista-kafka/src/main/java/me/escoffier/quarkus/coffeeshop/Beverage.java new file mode 100644 index 0000000..484364c --- /dev/null +++ b/barista-kafka/src/main/java/me/escoffier/quarkus/coffeeshop/Beverage.java @@ -0,0 +1,76 @@ +package me.escoffier.quarkus.coffeeshop; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection +public class Beverage { + + private String beverage; + private String customer; + private String preparedBy; + private String orderId; + private State preparationState; + + public enum State { + IN_QUEUE, + BEING_PREPARED, + READY; + } + + public Beverage() { + // Used by json-b + } + + public Beverage(Order order, String baristaName, State state) { + this.beverage = order.getProduct(); + this.customer = order.getName(); + this.orderId = order.getOrderId(); + this.preparedBy = baristaName; + this.preparationState = state; + } + + public String getBeverage() { + return beverage; + } + + public Beverage setBeverage(String beverage) { + this.beverage = beverage; + return this; + } + + public String getCustomer() { + return customer; + } + + public Beverage setCustomer(String customer) { + this.customer = customer; + return this; + } + + public String getPreparedBy() { + return preparedBy; + } + + public Beverage setPreparedBy(String preparedBy) { + this.preparedBy = preparedBy; + return this; + } + + public String getOrderId() { + return orderId; + } + + public Beverage setOrderId(String orderId) { + this.orderId = orderId; + return this; + } + + public State getPreparationState() { + return preparationState; + } + + public Beverage setPreparationState(State preparationState) { + this.preparationState = preparationState; + return this; + } +} diff --git a/barista-kafka/src/main/java/me/escoffier/quarkus/coffeeshop/KafkaBarista.java b/barista-kafka/src/main/java/me/escoffier/quarkus/coffeeshop/KafkaBarista.java new file mode 100644 index 0000000..30968cf --- /dev/null +++ b/barista-kafka/src/main/java/me/escoffier/quarkus/coffeeshop/KafkaBarista.java @@ -0,0 +1,45 @@ +package me.escoffier.quarkus.coffeeshop; + +import io.smallrye.reactive.messaging.annotations.Blocking; +import org.eclipse.microprofile.reactive.messaging.Incoming; +import org.eclipse.microprofile.reactive.messaging.Outgoing; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.enterprise.context.ApplicationScoped; +import java.util.Random; + +import static me.escoffier.quarkus.coffeeshop.Names.pickAName; + +@ApplicationScoped +public class KafkaBarista { + + private static final Logger LOGGER = LoggerFactory.getLogger("Kafka-Barista"); + + private String name = pickAName(); + + @Incoming("orders") + @Outgoing("queue") + @Blocking + public Beverage process(Order order) { + return prepare(order); + } + + Beverage prepare(Order order) { + int delay = getPreparationTime(); + try { + Thread.sleep(delay); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + LOGGER.info("Order {} for {} is ready", order.getProduct(), order.getName()); + return new Beverage(order, name, Beverage.State.READY); + } + + private Random random = new Random(); + + int getPreparationTime() { + return random.nextInt(5) * 1000; + } + +} diff --git a/barista-kafka/src/main/java/me/escoffier/quarkus/coffeeshop/Names.java b/barista-kafka/src/main/java/me/escoffier/quarkus/coffeeshop/Names.java new file mode 100644 index 0000000..f577760 --- /dev/null +++ b/barista-kafka/src/main/java/me/escoffier/quarkus/coffeeshop/Names.java @@ -0,0 +1,46 @@ +package me.escoffier.quarkus.coffeeshop; + +import java.util.Arrays; +import java.util.List; +import java.util.Random; + +class Names { + + private static final List VALUES = Arrays.asList( + "Olivia", + "Oliver", + "Amelia", + "George", + "Isla", + "Harry", + "Ava", + "Noah", + "Emily", + "Jack", + "Sophia", + "Charlie", + "Grace", + "Leo", + "Mia", + "Jacob", + "Poppy", + "Freddie", + "Ella", + "Alfie", + "Tom", + "Julie", + "Matt", + "Joe", + "Zoe" + ); + + static String pickAName() { + Random random = new Random(); + int index = random.nextInt(VALUES.size()); + return VALUES.get(index); + } + + private Names() { + // avoid direct instantiation + } +} diff --git a/barista-kafka/src/main/java/me/escoffier/quarkus/coffeeshop/Order.java b/barista-kafka/src/main/java/me/escoffier/quarkus/coffeeshop/Order.java new file mode 100644 index 0000000..d758e7b --- /dev/null +++ b/barista-kafka/src/main/java/me/escoffier/quarkus/coffeeshop/Order.java @@ -0,0 +1,38 @@ +package me.escoffier.quarkus.coffeeshop; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection +public class Order { + + private String product; + private String name; + private String orderId; + + public String getProduct() { + return product; + } + + public Order setProduct(String product) { + this.product = product; + return this; + } + + public String getName() { + return name; + } + + public Order setName(String name) { + this.name = name; + return this; + } + + public String getOrderId() { + return orderId; + } + + public Order setOrderId(String orderId) { + this.orderId = orderId; + return this; + } +} diff --git a/barista-kafka/src/main/java/me/escoffier/quarkus/coffeeshop/codecs/OrderDeserializer.java b/barista-kafka/src/main/java/me/escoffier/quarkus/coffeeshop/codecs/OrderDeserializer.java new file mode 100644 index 0000000..c469245 --- /dev/null +++ b/barista-kafka/src/main/java/me/escoffier/quarkus/coffeeshop/codecs/OrderDeserializer.java @@ -0,0 +1,13 @@ +package me.escoffier.quarkus.coffeeshop.codecs; + +import io.quarkus.kafka.client.serialization.JsonbDeserializer; +import io.quarkus.runtime.annotations.RegisterForReflection; +import me.escoffier.quarkus.coffeeshop.Order; + +@RegisterForReflection +public class OrderDeserializer extends JsonbDeserializer { + + public OrderDeserializer() { + super(Order.class); + } +} diff --git a/barista-kafka/src/main/resources/application.properties b/barista-kafka/src/main/resources/application.properties new file mode 100644 index 0000000..38a51d2 --- /dev/null +++ b/barista-kafka/src/main/resources/application.properties @@ -0,0 +1,11 @@ +quarkus.http.port=8082 + +## Orders topic +mp.messaging.incoming.orders.connector=smallrye-kafka +mp.messaging.incoming.orders.value.deserializer=me.escoffier.quarkus.coffeeshop.codecs.OrderDeserializer +mp.messaging.incoming.orders.auto.offset.reset=earliest +mp.messaging.incoming.orders.group.id=baristas + +## Queue topic +mp.messaging.outgoing.queue.connector=smallrye-kafka +mp.messaging.outgoing.queue.value.serializer=io.quarkus.kafka.client.serialization.JsonbSerializer diff --git a/barista-kafka/src/test/java/me/escoffier/quarkus/coffeeshop/BaristaTest.java b/barista-kafka/src/test/java/me/escoffier/quarkus/coffeeshop/BaristaTest.java new file mode 100644 index 0000000..ebf29b0 --- /dev/null +++ b/barista-kafka/src/test/java/me/escoffier/quarkus/coffeeshop/BaristaTest.java @@ -0,0 +1,48 @@ +package me.escoffier.quarkus.coffeeshop; + + +import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.junit.QuarkusTest; +import io.smallrye.reactive.messaging.connectors.InMemoryConnector; +import io.smallrye.reactive.messaging.connectors.InMemorySink; +import io.smallrye.reactive.messaging.connectors.InMemorySource; +import org.eclipse.microprofile.reactive.messaging.Message; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import javax.enterprise.inject.Any; +import javax.inject.Inject; +import java.util.List; + +import static org.awaitility.Awaitility.await; + +@QuarkusTest +@QuarkusTestResource(KafkaTestResourceLifecycleManager.class) +class BaristaTest { + + @Inject + @Any + InMemoryConnector connector; + + @Test + void testProcessOrder() { + InMemorySource orders = connector.source("orders"); + InMemorySink queue = connector.sink("queue"); + + Order order = new Order(); + order.setProduct("coffee"); + order.setName("Coffee lover"); + order.setOrderId("1234"); + + orders.send(order); + + await().>>until(queue::received, t -> t.size() == 1); + + Beverage queuedBeverage = queue.received().get(0).getPayload(); + Assertions.assertEquals(Beverage.State.READY, queuedBeverage.getPreparationState()); + Assertions.assertEquals("coffee", queuedBeverage.getBeverage()); + Assertions.assertEquals("Coffee lover", queuedBeverage.getCustomer()); + Assertions.assertEquals("1234", queuedBeverage.getOrderId()); + } + +} diff --git a/barista-kafka/src/test/java/me/escoffier/quarkus/coffeeshop/KafkaTestResourceLifecycleManager.java b/barista-kafka/src/test/java/me/escoffier/quarkus/coffeeshop/KafkaTestResourceLifecycleManager.java new file mode 100644 index 0000000..ed415bf --- /dev/null +++ b/barista-kafka/src/test/java/me/escoffier/quarkus/coffeeshop/KafkaTestResourceLifecycleManager.java @@ -0,0 +1,25 @@ +package me.escoffier.quarkus.coffeeshop; + +import io.quarkus.test.common.QuarkusTestResourceLifecycleManager; +import io.smallrye.reactive.messaging.connectors.InMemoryConnector; + +import java.util.HashMap; +import java.util.Map; + +public class KafkaTestResourceLifecycleManager implements QuarkusTestResourceLifecycleManager { + + @Override + public Map start() { + Map env = new HashMap<>(); + Map props1 = InMemoryConnector.switchIncomingChannelsToInMemory("orders"); + Map props2 = InMemoryConnector.switchOutgoingChannelsToInMemory("queue"); + env.putAll(props1); + env.putAll(props2); + return env; + } + + @Override + public void stop() { + InMemoryConnector.clear(); + } +} diff --git a/barista-node/.eslintrc.json b/barista-node/.eslintrc.json new file mode 100644 index 0000000..bf3cb18 --- /dev/null +++ b/barista-node/.eslintrc.json @@ -0,0 +1,16 @@ +{ + "env": { + "commonjs": true, + "es2021": true, + "node": true + }, + "extends": ["airbnb-base", "prettier"], + "plugins": ["prettier"], + "parserOptions": { + "ecmaVersion": 12 + }, + "rules": { + "no-console": "off", + "prettier/prettier": ["error"] + } +} diff --git a/barista-node/.gitignore b/barista-node/.gitignore new file mode 100644 index 0000000..1f22b9c --- /dev/null +++ b/barista-node/.gitignore @@ -0,0 +1,116 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* diff --git a/barista-node/.prettierrc b/barista-node/.prettierrc new file mode 100644 index 0000000..234fbb3 --- /dev/null +++ b/barista-node/.prettierrc @@ -0,0 +1,6 @@ +{ + "semi": true, + "singleQuote": true, + "arrowParens": "avoid", + "trailingComma": "es5" +} diff --git a/barista-node/main.js b/barista-node/main.js new file mode 100644 index 0000000..c0e1ea6 --- /dev/null +++ b/barista-node/main.js @@ -0,0 +1,34 @@ +const { Kafka } = require('kafkajs'); +const logger = require('./utils/logger'); +const barista = require('./models/barista'); + +const kafka = new Kafka({ + clientId: 'barista-kafka-node', + brokers: [process.env.KAFKA_BOOTSTRAP_SERVERS || 'localhost:9092'], +}); + +const consumer = kafka.consumer({ groupId: 'baristas' }); +const producer = kafka.producer(); + +const run = async () => { + await consumer.connect(); + await producer.connect(); + + await consumer.subscribe({ topic: 'orders', fromBeginning: true }); + + await consumer.run({ + eachMessage: async ({ message }) => { + const order = JSON.parse(message.value.toString()); + + const beverage = await barista.prepare(order); + logger.info(`Order ${order.orderId} for ${order.name} is ready`); + + await producer.send({ + topic: 'queue', + messages: [{ value: JSON.stringify({ ...beverage }) }], + }); + }, + }); +}; + +run().catch(e => logger.error(e.message)); diff --git a/barista-node/models/barista.js b/barista-node/models/barista.js new file mode 100644 index 0000000..08847ae --- /dev/null +++ b/barista-node/models/barista.js @@ -0,0 +1,31 @@ +const { pickName, getPreparationTime } = require('../utils/random'); + +const barista = pickName(); + +const state = { + IN_QUEUE: 'IN_QUEUE', + BEING_PREPARED: 'BEING_PREPARED', + READY: 'READY', +}; + +function prepare(order) { + return new Promise(resolve => { + const delay = getPreparationTime(); + setTimeout( + () => + resolve({ + orderId: order.orderId, + beverage: order.product, + customer: order.name, + preparedBy: barista, + preparationState: state.READY, + }), + delay + ); + }); +} + +module.exports = { + name: barista, + prepare, +}; diff --git a/barista-node/package.json b/barista-node/package.json new file mode 100644 index 0000000..a177676 --- /dev/null +++ b/barista-node/package.json @@ -0,0 +1,25 @@ +{ + "name": "barista-kafka-node", + "version": "1.0.0", + "description": "", + "main": "main.js", + "scripts": { + "start": "node .", + "lint": "eslint ." + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "chalk": "^4.1.0", + "kafkajs": "^1.14.0" + }, + "devDependencies": { + "eslint": "^7.11.0", + "eslint-config-airbnb-base": "^14.2.0", + "eslint-config-prettier": "^6.13.0", + "eslint-plugin-import": "^2.22.1", + "eslint-plugin-prettier": "^3.1.4", + "prettier": "^2.1.2" + } +} diff --git a/barista-node/utils/logger.js b/barista-node/utils/logger.js new file mode 100644 index 0000000..e69a05b --- /dev/null +++ b/barista-node/utils/logger.js @@ -0,0 +1,19 @@ +const chalk = require('chalk'); + +function info(message) { + console.log(`[${chalk.blue.bold('INFO')}] ${message}`); +} + +function warning(message) { + console.log(`[${chalk.yellow.bold('WARNING')}] ${message}`); +} + +function error(message) { + console.log(`[${chalk.red.bold('ERROR')}] ${message}`); +} + +module.exports = { + info, + warning, + error, +}; diff --git a/barista-node/utils/random.js b/barista-node/utils/random.js new file mode 100644 index 0000000..8e2058a --- /dev/null +++ b/barista-node/utils/random.js @@ -0,0 +1,45 @@ +const names = [ + 'Olivia', + 'Oliver', + 'Amelia', + 'George', + 'Isla', + 'Harry', + 'Ava', + 'Noah', + 'Emily', + 'Jack', + 'Sophia', + 'Charlie', + 'Grace', + 'Leo', + 'Mia', + 'Jacob', + 'Poppy', + 'Freddie', + 'Ella', + 'Alfie', + 'Thomas', + 'Julie', + 'Matt', + 'Joe', + 'Zoe', +]; + +function getRandomInt(max) { + return Math.floor(Math.random() * Math.floor(max)); +} + +function pickName() { + const index = getRandomInt(names.length); + return names[index]; +} + +function getPreparationTime() { + return getRandomInt(5) * 1000; +} + +module.exports = { + pickName, + getPreparationTime, +}; diff --git a/coffeeshop-service/pom.xml b/coffeeshop-service/pom.xml new file mode 100644 index 0000000..6ef1b77 --- /dev/null +++ b/coffeeshop-service/pom.xml @@ -0,0 +1,133 @@ + + + 4.0.0 + + me.escoffier.quarkus.reactive + quarkus-coffeeshop-demo + 1.0-SNAPSHOT + .. + + coffeeshop-service + + + io.quarkus + quarkus-vertx + + + io.quarkus + quarkus-resteasy + + + io.quarkus + quarkus-resteasy-mutiny + + + io.quarkus + quarkus-resteasy-jsonb + + + io.quarkus + quarkus-jsonp + + + io.quarkus + quarkus-rest-client + + + io.quarkus + quarkus-smallrye-reactive-messaging + + + io.quarkus + quarkus-smallrye-reactive-messaging-kafka + + + io.quarkus + quarkus-smallrye-openapi + + + io.quarkus + quarkus-smallrye-health + + + io.quarkus + quarkus-spring-web + + + io.quarkus + quarkus-spring-di + + + + + + io.quarkus + quarkus-maven-plugin + ${quarkus.version} + + + + build + + + + + + maven-surefire-plugin + ${surefire-plugin.version} + + + org.jboss.logmanager.LogManager + + + + + + + + native + + + native + + + + + + io.quarkus + quarkus-maven-plugin + ${quarkus.version} + + + + native-image + + + true + + + + + + maven-failsafe-plugin + ${surefire-plugin.version} + + + + integration-test + verify + + + + ${project.build.directory}/${project.build.finalName}-runner + + + + + + + + + + diff --git a/coffeeshop-service/src/main/docker/Dockerfile.native b/coffeeshop-service/src/main/docker/Dockerfile.native new file mode 100644 index 0000000..2dc784a --- /dev/null +++ b/coffeeshop-service/src/main/docker/Dockerfile.native @@ -0,0 +1,22 @@ +#### +# This Dockerfile is used in order to build a container that runs the Quarkus application in native (no JVM) mode +# +# Before building the docker image run: +# +# mvn package -Pnative -Dquarkus.native.container-build=true +# +# Then, build the image with: +# +# docker build -f src/main/docker/Dockerfile.native -t coffee/coffee-shop . +# +# Then run the container using: +# +# docker run -i --rm -p 8080:8080 coffee/coffee-shop +# +### +FROM registry.access.redhat.com/ubi8/ubi-minimal:8.1 +WORKDIR /work/ +COPY target/*-runner /work/application +RUN chmod 775 /work /work/application +EXPOSE 8080 +CMD ["./application", "-Dquarkus.http.host=0.0.0.0"] \ No newline at end of file diff --git a/coffeeshop-service/src/main/java/me/escoffier/quarkus/coffeeshop/CoffeeShopResource.java b/coffeeshop-service/src/main/java/me/escoffier/quarkus/coffeeshop/CoffeeShopResource.java new file mode 100644 index 0000000..69c9faa --- /dev/null +++ b/coffeeshop-service/src/main/java/me/escoffier/quarkus/coffeeshop/CoffeeShopResource.java @@ -0,0 +1,55 @@ +package me.escoffier.quarkus.coffeeshop; + +import io.smallrye.mutiny.Uni; +import me.escoffier.quarkus.coffeeshop.http.BaristaService; +import me.escoffier.quarkus.coffeeshop.model.Beverage; +import me.escoffier.quarkus.coffeeshop.model.Order; +import org.eclipse.microprofile.reactive.messaging.Channel; +import org.eclipse.microprofile.reactive.messaging.Emitter; +import org.eclipse.microprofile.rest.client.inject.RestClient; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.time.Duration; +import java.util.UUID; + +@RestController +@RequestMapping("/") +public class CoffeeShopResource { + + @Autowired + @RestClient + BaristaService barista; + + @PostMapping("/http") + public Uni http(Order order) { + return barista.order(order.setOrderId(getId())) + .onItem().invoke(beverage -> beverage.setPreparationState(Beverage.State.READY)) + .ifNoItem().after(Duration.ofMillis(1500)).fail() + .onFailure().recoverWithItem(createFallbackBeverage(order)); + } + + private Beverage createFallbackBeverage(Order order) { + return new Beverage(order, null, Beverage.State.FAILED); + } + + // Orders emitter (orders) + @Autowired @Channel("orders") Emitter orders; + // Queue emitter (beverages) + @Autowired @Channel("queue") Emitter queue; + + @PostMapping("/messaging") + public Order messaging(Order order) { + order = order.setOrderId(getId()); + queue.send(Beverage.queued(order)); + orders.send(order); + return order; + } + + private String getId() { + return UUID.randomUUID().toString(); + } + +} diff --git a/coffeeshop-service/src/main/java/me/escoffier/quarkus/coffeeshop/codecs/BeverageDeserializer.java b/coffeeshop-service/src/main/java/me/escoffier/quarkus/coffeeshop/codecs/BeverageDeserializer.java new file mode 100644 index 0000000..3298c72 --- /dev/null +++ b/coffeeshop-service/src/main/java/me/escoffier/quarkus/coffeeshop/codecs/BeverageDeserializer.java @@ -0,0 +1,13 @@ +package me.escoffier.quarkus.coffeeshop.codecs; + +import io.quarkus.kafka.client.serialization.JsonbDeserializer; +import io.quarkus.runtime.annotations.RegisterForReflection; +import me.escoffier.quarkus.coffeeshop.model.Beverage; + +@RegisterForReflection +public class BeverageDeserializer extends JsonbDeserializer { + + public BeverageDeserializer() { + super(Beverage.class); + } +} diff --git a/coffeeshop-service/src/main/java/me/escoffier/quarkus/coffeeshop/dashboard/BoardResource.java b/coffeeshop-service/src/main/java/me/escoffier/quarkus/coffeeshop/dashboard/BoardResource.java new file mode 100644 index 0000000..fb29824 --- /dev/null +++ b/coffeeshop-service/src/main/java/me/escoffier/quarkus/coffeeshop/dashboard/BoardResource.java @@ -0,0 +1,41 @@ +package me.escoffier.quarkus.coffeeshop.dashboard; + +import io.smallrye.mutiny.Multi; +import me.escoffier.quarkus.coffeeshop.model.Beverage; +import org.eclipse.microprofile.reactive.messaging.Channel; +import org.reactivestreams.Publisher; + +import javax.inject.Inject; +import javax.json.bind.Jsonb; +import javax.json.bind.JsonbBuilder; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.MediaType; +import java.time.Duration; + +@Path("/queue") +public class BoardResource { + + @Inject + @Channel("beverages") + Multi queue; + + private Jsonb json = JsonbBuilder.create(); + + @GET + @Produces(MediaType.SERVER_SENT_EVENTS) + public Publisher getQueue() { + return Multi.createBy().merging() + .streams( + queue.map(b -> json.toJson(b)), + getPingStream() + ); + } + + Multi getPingStream() { + return Multi.createFrom().ticks().every(Duration.ofSeconds(10)) + .onItem().transform(x -> "{}"); + } + +} diff --git a/coffeeshop-service/src/main/java/me/escoffier/quarkus/coffeeshop/http/BaristaService.java b/coffeeshop-service/src/main/java/me/escoffier/quarkus/coffeeshop/http/BaristaService.java new file mode 100644 index 0000000..825ebbf --- /dev/null +++ b/coffeeshop-service/src/main/java/me/escoffier/quarkus/coffeeshop/http/BaristaService.java @@ -0,0 +1,23 @@ +package me.escoffier.quarkus.coffeeshop.http; + +import io.smallrye.mutiny.Uni; +import me.escoffier.quarkus.coffeeshop.model.Beverage; +import me.escoffier.quarkus.coffeeshop.model.Order; +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; + +import javax.ws.rs.Consumes; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.MediaType; + +@Path("/barista") +@RegisterRestClient +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public interface BaristaService { + + @POST + Uni order(Order order); + +} diff --git a/coffeeshop-service/src/main/java/me/escoffier/quarkus/coffeeshop/model/Beverage.java b/coffeeshop-service/src/main/java/me/escoffier/quarkus/coffeeshop/model/Beverage.java new file mode 100644 index 0000000..b0b157f --- /dev/null +++ b/coffeeshop-service/src/main/java/me/escoffier/quarkus/coffeeshop/model/Beverage.java @@ -0,0 +1,81 @@ +package me.escoffier.quarkus.coffeeshop.model; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection +public class Beverage { + + private String beverage; + private String customer; + private String preparedBy; + private String orderId; + private State preparationState; + + public Beverage() { + + } + + public static Beverage queued(Order order) { + return new Beverage(order, null, State.IN_QUEUE); + } + + public enum State { + IN_QUEUE, + BEING_PREPARED, + READY, + FAILED; + } + + public Beverage(Order order, String baristaName, State state) { + this.beverage = order.getProduct(); + this.customer = order.getName(); + this.orderId = order.getOrderId(); + this.preparedBy = baristaName; + this.preparationState = state; + } + + public String getBeverage() { + return beverage; + } + + public Beverage setBeverage(String beverage) { + this.beverage = beverage; + return this; + } + + public String getCustomer() { + return customer; + } + + public Beverage setCustomer(String customer) { + this.customer = customer; + return this; + } + + public String getPreparedBy() { + return preparedBy; + } + + public Beverage setPreparedBy(String preparedBy) { + this.preparedBy = preparedBy; + return this; + } + + public String getOrderId() { + return orderId; + } + + public Beverage setOrderId(String orderId) { + this.orderId = orderId; + return this; + } + + public State getPreparationState() { + return preparationState; + } + + public Beverage setPreparationState(State preparationState) { + this.preparationState = preparationState; + return this; + } +} diff --git a/coffeeshop-service/src/main/java/me/escoffier/quarkus/coffeeshop/model/Order.java b/coffeeshop-service/src/main/java/me/escoffier/quarkus/coffeeshop/model/Order.java new file mode 100644 index 0000000..797e026 --- /dev/null +++ b/coffeeshop-service/src/main/java/me/escoffier/quarkus/coffeeshop/model/Order.java @@ -0,0 +1,38 @@ +package me.escoffier.quarkus.coffeeshop.model; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection +public class Order { + + private String product; + private String name; + private String orderId; + + public String getProduct() { + return product; + } + + public Order setProduct(String product) { + this.product = product; + return this; + } + + public String getName() { + return name; + } + + public Order setName(String name) { + this.name = name; + return this; + } + + public String getOrderId() { + return orderId; + } + + public Order setOrderId(String orderId) { + this.orderId = orderId; + return this; + } +} diff --git a/coffeeshop-service/src/main/resources/META-INF/resources/index.html b/coffeeshop-service/src/main/resources/META-INF/resources/index.html new file mode 100644 index 0000000..4996c75 --- /dev/null +++ b/coffeeshop-service/src/main/resources/META-INF/resources/index.html @@ -0,0 +1,170 @@ + + + + + Queue + + + + + + +
+ +

Order

+ +
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+ +
+
+
+ +
+ +
+ +
+ +

Queue

+ + + + + + + + + + + + + +
CustomerProductPrepared ByState
+ +
+ + + + + + + + + + + + \ No newline at end of file diff --git a/coffeeshop-service/src/main/resources/application.properties b/coffeeshop-service/src/main/resources/application.properties new file mode 100644 index 0000000..764f9ee --- /dev/null +++ b/coffeeshop-service/src/main/resources/application.properties @@ -0,0 +1,23 @@ + +quarkus.http.port=8080 + +## HTTP Client +me.escoffier.quarkus.coffeeshop.http.BaristaService/mp-rest/url=http://localhost:8081 + +#kafka.bootstrap.servers=my-kafka-kafka-brokers:9092 + +## Orders topic +mp.messaging.outgoing.orders.connector=smallrye-kafka +mp.messaging.outgoing.orders.value.serializer=io.quarkus.kafka.client.serialization.JsonbSerializer + +## Queue topic - write +mp.messaging.outgoing.queue.connector=smallrye-kafka +mp.messaging.outgoing.queue.value.serializer=io.quarkus.kafka.client.serialization.JsonbSerializer +mp.messaging.outgoing.queue.broadcast=true + +## Beverage / queue topic +mp.messaging.incoming.beverages.connector=smallrye-kafka +mp.messaging.incoming.beverages.topic=queue +mp.messaging.incoming.beverages.broadcast=true +mp.messaging.incoming.beverages.value.deserializer=me.escoffier.quarkus.coffeeshop.codecs.BeverageDeserializer + diff --git a/create-topics.sh b/create-topics.sh new file mode 100755 index 0000000..92b98be --- /dev/null +++ b/create-topics.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +kafka-topics --bootstrap-server localhost:9092 --create --partitions 4 --replication-factor 1 --topic orders +kafka-topics --bootstrap-server localhost:9092 --list \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..6dc5614 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,30 @@ +version: '2' + +services: + + zookeeper: + image: strimzi/kafka:0.15.0-kafka-2.3.1 + command: [ + "sh", "-c", + "bin/zookeeper-server-start.sh config/zookeeper.properties" + ] + ports: + - "2181:2181" + environment: + LOG_DIR: /tmp/logs + + kafka: + image: strimzi/kafka:0.15.0-kafka-2.3.1 + command: [ + "sh", "-c", + "bin/kafka-server-start.sh config/server.properties --override listeners=$${KAFKA_LISTENERS} --override advertised.listeners=$${KAFKA_ADVERTISED_LISTENERS} --override zookeeper.connect=$${KAFKA_ZOOKEEPER_CONNECT}" + ] + depends_on: + - zookeeper + ports: + - "9092:9092" + environment: + LOG_DIR: "/tmp/logs" + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092 + KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092 + KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 \ No newline at end of file diff --git a/kubernetes/README.md b/kubernetes/README.md new file mode 100644 index 0000000..206fc19 --- /dev/null +++ b/kubernetes/README.md @@ -0,0 +1,107 @@ +# Deployment to Kubernetes + +## Prerequisites + +1. You need a Kubernetes cluster and have access to it - as administrators as you need to install operators +2. You need to be able to push images to this Kubernetes cluster. The following instruction are using the internal image registry (offered by OpenShift) + but you can use Docker Hub +3. You need `helm` to be installed on your machine + +## Deploying the infrastructure + +Run: + +```bash +cd kubernetes +./install-infra.sh +cd .. +``` + +It installs: + +* Strimzi and instantiate a Kafka cluster +* Keda + +It also creates the `coffee` namespace used for the application. + +## Building the native executables + +Be sure to be authenticated to the registry you are going to push to. For instance: + +```bash +docker login -u $KUBERNETES_USER -p $KUBERNETES_TOKEN $REGISTRY +``` + +NOTE: On OpenShift, if enabled, you can access the internal registry. First, get the url using: +`export REGISTRY=$(oc get route -n openshift-image-registry -o jsonpath='{$.items[*].spec.host}')` + +From the project root run: + +```bash +mvn clean package -Pnative -Dquarkus.native.container-build=true +``` + +This command as generated the native executable using the Linux 64 architecture. + +## Building and pushing the container images + +Run, from the project root + +```shell +cd coffeeshop-service +docker build -f src/main/docker/Dockerfile.native -t coffee/coffee-shop . +docker tag coffee/coffee-shop $REGISTRY/coffee/coffee-shop +docker push $REGISTRY/coffee/coffee-shop +cd .. + +cd barista-http +docker build -f src/main/docker/Dockerfile.native -t coffee/barista-http . +docker tag coffee/barista-http $REGISTRY/coffee/barista-http +docker push $REGISTRY/coffee/barista-http +cd .. + +cd barista-kafka +docker build -f src/main/docker/Dockerfile.native -t coffee/barista-kafka . +docker tag coffee/barista-kafka $REGISTRY/coffee/barista-kafka +docker push $REGISTRY/coffee/barista-kafka +cd .. +``` + +On OpenShift you can check the image stream using: + +```shell +$ oc get is -n coffee +barista-http .../coffee/barista-http latest About a minute ago +barista-kafka .../coffee/barista-kafka latest 28 seconds ago +coffee-shop .../coffee/coffee-shop latest 5 seconds ago +``` + +## Deploy the application + +Open the `kubernetes/charts/values.yaml`, and if needed, edit the registry part of the image names. + +Then, from the project root, run: + +```shell +helm install coffee-v1 kubernetes/charts -n coffee --wait --timeout 300s +oc apply -f kubernetes/route.yaml -n coffee +export COFFEE_URL="https://$(oc get route -n coffee -o jsonpath='{$.items[*].spec.host}')" +echo "Open url: ${COFFEE_URL}" +``` + + +## Uninstalling + +```shell +cd kubernetes +./uninstall.sh +``` + +## Updating the charts + +```shell +helm uninstall coffee-v1 -n coffee +oc delete KafkaTopic -name orders -n kafka +oc delete KafkaTopic -name queue -n kafka +helm install coffee-v1 kubernetes/charts -n coffee --wait --timeout 300s +``` diff --git a/kubernetes/charts/Chart.yaml b/kubernetes/charts/Chart.yaml new file mode 100644 index 0000000..82e4ea1 --- /dev/null +++ b/kubernetes/charts/Chart.yaml @@ -0,0 +1,19 @@ +--- +apiVersion: v2 +name: coffeeshop-chart +description: A Helm chart for the coffee-shop demo + +# Keda chart must be installed manually first (into its own namespace). +# - name: keda +# version: 1.0.0 +# repository: https://kedacore.github.io/charts/ + +# Strimzi chart must be installed manually first (into its own namespace). +# The Kafka cluster should then be created (into Values.kafka.namespace, which defaults to 'kafka'). +# - name: strimzi-kafka-operator +# version: 0.14.0 +# repository: https://strimzi.io/charts/ + +type: application +version: 0.1.0 # chart version +appVersion: 0.1.0 # application version \ No newline at end of file diff --git a/kubernetes/charts/templates/deployment/barista-http.yaml b/kubernetes/charts/templates/deployment/barista-http.yaml new file mode 100644 index 0000000..fa0f173 --- /dev/null +++ b/kubernetes/charts/templates/deployment/barista-http.yaml @@ -0,0 +1,23 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: barista-http +spec: + replicas: {{ .Values.baristaHttp.replicaCount }} + selector: + matchLabels: + app: barista-http + template: + metadata: + labels: + app: barista-http + spec: + containers: + - name: barista-http + image: "{{ .Values.baristaHttp.image.repository }}:{{ .Values.baristaHttp.image.version }}" + ports: + - containerPort: 8080 + imagePullPolicy: {{ .Values.baristaHttp.image.pullPolicy }} + env: + - name: QUARKUS_HTTP_PORT + value: "8080" diff --git a/kubernetes/charts/templates/deployment/barista-kafka.yaml b/kubernetes/charts/templates/deployment/barista-kafka.yaml new file mode 100644 index 0000000..b21ba43 --- /dev/null +++ b/kubernetes/charts/templates/deployment/barista-kafka.yaml @@ -0,0 +1,25 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: barista-kafka +spec: + replicas: {{ .Values.baristaKafka.replicaCount }} + selector: + matchLabels: + app: barista-kafka + template: + metadata: + labels: + app: barista-kafka + spec: + containers: + - name: barista-kafka + image: "{{ .Values.baristaKafka.image.repository }}:{{ .Values.baristaKafka.image.version }}" + ports: + - containerPort: 8080 + imagePullPolicy: {{ .Values.baristaKafka.image.pullPolicy }} + env: + - name: KAFKA_BOOTSTRAP_SERVERS + value: "{{ .Values.kafka.bootstrap.service }}.{{ .Values.kafka.namespace }}:{{ .Values.kafka.bootstrap.port }}" + - name: QUARKUS_HTTP_PORT + value: "8080" diff --git a/kubernetes/charts/templates/deployment/coffee-shop.yaml b/kubernetes/charts/templates/deployment/coffee-shop.yaml new file mode 100644 index 0000000..04c0192 --- /dev/null +++ b/kubernetes/charts/templates/deployment/coffee-shop.yaml @@ -0,0 +1,27 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: coffee-shop +spec: + replicas: {{ .Values.coffeeshopService.replicaCount }} + selector: + matchLabels: + app: coffee-shop + template: + metadata: + labels: + app: coffee-shop + spec: + containers: + - name: coffee-shop + image: "{{ .Values.coffeeshopService.image.repository }}:{{ .Values.coffeeshopService.image.version }}" + ports: + - containerPort: 8080 + imagePullPolicy: {{ .Values.coffeeshopService.image.pullPolicy }} + env: + - name: KAFKA_BOOTSTRAP_SERVERS + value: "{{ .Values.kafka.bootstrap.service }}.{{ .Values.kafka.namespace }}:{{ .Values.kafka.bootstrap.port }}" + - name: QUARKUS_HTTP_PORT + value: "8080" + - name: ME_ESCOFFIER_QUARKUS_COFFEESHOP_HTTP_BARISTASERVICE_MP_REST_URL + value: http://barista-http:8080 diff --git a/kubernetes/charts/templates/kafka/barista-scaler.yaml b/kubernetes/charts/templates/kafka/barista-scaler.yaml new file mode 100644 index 0000000..76e59af --- /dev/null +++ b/kubernetes/charts/templates/kafka/barista-scaler.yaml @@ -0,0 +1,20 @@ +apiVersion: keda.k8s.io/v1alpha1 +kind: ScaledObject +metadata: + name: barista-kafka + labels: + deploymentName: barista-kafka +spec: + scaleTargetRef: + deploymentName: barista-kafka + pollingInterval: 1 + cooldownPeriod: 300 + minReplicaCount: 0 + maxReplicaCount: 5 + triggers: + - type: kafka + metadata: + topic: orders + brokerList: {{ .Values.kafka.bootstrap.service }}.{{ .Values.kafka.namespace }}:{{ .Values.kafka.bootstrap.port }} + consumerGroup: baristas + lagThreshold: '3' \ No newline at end of file diff --git a/kubernetes/charts/templates/kafka/orders.yaml b/kubernetes/charts/templates/kafka/orders.yaml new file mode 100644 index 0000000..3a54652 --- /dev/null +++ b/kubernetes/charts/templates/kafka/orders.yaml @@ -0,0 +1,10 @@ +apiVersion: kafka.strimzi.io/v1beta1 +kind: KafkaTopic +metadata: + name: orders + namespace: {{ .Values.kafka.namespace }} + labels: + strimzi.io/cluster: {{ .Values.kafka.cluster }} +spec: + partitions: 5 + replicas: 1 \ No newline at end of file diff --git a/kubernetes/charts/templates/kafka/queue.yaml b/kubernetes/charts/templates/kafka/queue.yaml new file mode 100644 index 0000000..7273eea --- /dev/null +++ b/kubernetes/charts/templates/kafka/queue.yaml @@ -0,0 +1,10 @@ +apiVersion: kafka.strimzi.io/v1beta1 +kind: KafkaTopic +metadata: + name: queue + namespace: {{ .Values.kafka.namespace }} + labels: + strimzi.io/cluster: {{ .Values.kafka.cluster }} +spec: + partitions: 1 + replicas: 1 \ No newline at end of file diff --git a/kubernetes/charts/templates/services/barista-http.yaml b/kubernetes/charts/templates/services/barista-http.yaml new file mode 100644 index 0000000..8b939fb --- /dev/null +++ b/kubernetes/charts/templates/services/barista-http.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: barista-http +spec: + ports: + - port: 8080 + protocol: TCP + targetPort: 8080 + name: http + selector: + app: barista-http \ No newline at end of file diff --git a/kubernetes/charts/templates/services/coffee-shop.yaml b/kubernetes/charts/templates/services/coffee-shop.yaml new file mode 100644 index 0000000..70bbd19 --- /dev/null +++ b/kubernetes/charts/templates/services/coffee-shop.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: coffee-shop +spec: + ports: + - port: 8080 + protocol: TCP + targetPort: 8080 + name: http + selector: + app: coffee-shop diff --git a/kubernetes/charts/values.yaml b/kubernetes/charts/values.yaml new file mode 100644 index 0000000..1d4c93c --- /dev/null +++ b/kubernetes/charts/values.yaml @@ -0,0 +1,41 @@ +--- +baristaKafka: + replicaCount: 1 + image: + repository: image-registry.openshift-image-registry.svc:5000/coffee/barista-kafka + version: latest + pullPolicy: IfNotPresent + +baristaHttp: + replicaCount: 1 + image: + repository: image-registry.openshift-image-registry.svc:5000/coffee/barista-http + version: latest + pullPolicy: IfNotPresent + +coffeeshopService: + replicaCount: 1 + image: + repository: image-registry.openshift-image-registry.svc:5000/coffee/coffee-shop + version: latest + pullPolicy: IfNotPresent + +# The namespace and service name for the Kafka server. +kafka: + namespace: kafka + cluster: my-cluster + bootstrap: + service: my-cluster-kafka-bootstrap + port: 9092 + +resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m +# memory: 128Mi \ No newline at end of file diff --git a/kubernetes/install-infra.sh b/kubernetes/install-infra.sh new file mode 100755 index 0000000..8b70384 --- /dev/null +++ b/kubernetes/install-infra.sh @@ -0,0 +1,22 @@ +#!/bin/sh + +# Create namespaces for Strimzi and Kafka +kubectl create ns strimzi +kubectl create ns kafka + +# Install Strimzi Helm chart +helm repo add strimzi https://strimzi.io/charts +helm install strimzi strimzi/strimzi-kafka-operator -n strimzi --set watchNamespaces={kafka} --wait --timeout 300s + +# Install Strimzi custom resource, and wait for cluster creation +kubectl apply -f kafka-strimzi.yaml -n kafka +kubectl wait --for=condition=Ready kafkas/my-cluster -n kafka --timeout 180s + +# Create namespace for Keda +kubectl create ns keda + +# Install Keda Helm chart +helm repo add kedacore https://kedacore.github.io/charts +helm install keda kedacore/keda -n keda --wait --timeout 300s + +kubectl create ns coffee \ No newline at end of file diff --git a/kubernetes/kafka-strimzi.yaml b/kubernetes/kafka-strimzi.yaml new file mode 100644 index 0000000..32282e5 --- /dev/null +++ b/kubernetes/kafka-strimzi.yaml @@ -0,0 +1,26 @@ +apiVersion: kafka.strimzi.io/v1beta1 +kind: Kafka +metadata: + name: my-cluster + namespace: kafka +spec: + kafka: + version: 2.4.0 + replicas: 3 + listeners: + plain: {} + tls: {} + config: + offsets.topic.replication.factor: 3 + transaction.state.log.replication.factor: 3 + transaction.state.log.min.isr: 2 + log.message.format.version: '2.3' + storage: + type: ephemeral + zookeeper: + replicas: 3 + storage: + type: ephemeral + entityOperator: + topicOperator: {} + userOperator: {} diff --git a/kubernetes/post-install.sh b/kubernetes/post-install.sh new file mode 100755 index 0000000..2377b81 --- /dev/null +++ b/kubernetes/post-install.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# Display overall system state for kafka and coffee namespaces +kubectl get services -n kafka +kubectl get services -n coffee + +# Get NodePort for coffeeshop-service +NODE_PORT=$(kubectl get -o jsonpath="{.spec.ports[0].nodePort}" services coffee-v1-coffeeshop-service --namespace coffee) +# Get external IP address for node (assumes single node). +NODE_IP=$(kubectl get nodes -o jsonpath='{ $.items[*].status.addresses[?(@.type=="ExternalIP")].address }') +# If there is no ExternalIP, assume localhost +echo "Order coffees at http://${NODE_IP:-"localhost"}:${NODE_PORT}/" \ No newline at end of file diff --git a/kubernetes/route.yaml b/kubernetes/route.yaml new file mode 100644 index 0000000..96ae6e7 --- /dev/null +++ b/kubernetes/route.yaml @@ -0,0 +1,15 @@ +kind: Route +apiVersion: v1 +metadata: + name: coffee-shop + namespace: coffee +spec: + to: + kind: Service + name: coffee-shop + weight: 100 + port: + targetPort: http + tls: + termination: edge + insecureEdgeTerminationPolicy: Redirect \ No newline at end of file diff --git a/kubernetes/uninstall.sh b/kubernetes/uninstall.sh new file mode 100755 index 0000000..fc83e40 --- /dev/null +++ b/kubernetes/uninstall.sh @@ -0,0 +1,28 @@ +#!/bin/sh + +# Uninstall coffeeshop-demo and kafka cluster +helm uninstall coffee-v1 -n coffee + +# Delete coffee namespace +kubectl delete ns coffee + +# Uninstall Keda Helm chart +helm uninstall keda -n keda + +# Remove Keda artifacts (not sure why helm uninstall doesn't clean this up?) +kubectl delete apiservice v1alpha1.keda.k8s.io +kubectl delete crd scaledobjects.keda.k8s.io +kubectl delete crd triggerauthentications.keda.k8s.io + +# Delete keda namespace +kubectl delete ns keda + +# Delete Kafka cluster +kubectl delete -f kafka-strimzi.yaml -n kafka + +# Uninstall Strimzi Helm chart +helm uninstall strimzi -n strimzi + +# Delete namespaces for Strimzi and Kafka +kubectl delete ns strimzi +kubectl delete ns kafka \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..9d951a1 --- /dev/null +++ b/pom.xml @@ -0,0 +1,42 @@ + + 4.0.0 + + me.escoffier.quarkus.reactive + quarkus-coffeeshop-demo + 1.0-SNAPSHOT + + pom + + + 1.8.1.Final + + UTF-8 + UTF-8 + + 11 + 11 + + 2.22.2 + + + + + + io.quarkus + quarkus-bom + ${quarkus.version} + pom + import + + + + + + barista-http + coffeeshop-service + barista-kafka + + + \ No newline at end of file