diff --git a/.github/workflows/build-and-push-ghcr.yml b/.github/workflows/build-and-push-ghcr.yml new file mode 100644 index 00000000..6fb22221 --- /dev/null +++ b/.github/workflows/build-and-push-ghcr.yml @@ -0,0 +1,61 @@ +name: Build and push image to GHCR + +on: + push: + branches: + - feat-sentimentIntegration + workflow_dispatch: {} + +permissions: + contents: read + packages: write + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository_owner }}/trader + +jobs: + build-and-push: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + - name: Build WAR (mvn package) + run: mvn -DskipTests package + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Extract Docker metadata (tags, labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=sentimentAnalysis + type=sha,format=short,prefix=sentimentAnalysis- + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + diff --git a/README.md b/README.md index e9b7491c..ec0e48a1 100644 --- a/README.md +++ b/README.md @@ -80,4 +80,4 @@ docker push mycluster.icp:8500/stock-trader/trader:latest helm repo add ibm-charts https://raw.githubusercontent.com/IBM/charts/master/repo/stable/ helm install ibm-charts/ibm-websphere-liberty -f manifests/trader-values.yaml -n trader --namespace stock-trader --tls -``` +``` \ No newline at end of file diff --git a/src/main/java/com/ibm/hybrid/cloud/sample/stocktrader/trader/SentimentProxy.java b/src/main/java/com/ibm/hybrid/cloud/sample/stocktrader/trader/SentimentProxy.java new file mode 100644 index 00000000..b2df01f7 --- /dev/null +++ b/src/main/java/com/ibm/hybrid/cloud/sample/stocktrader/trader/SentimentProxy.java @@ -0,0 +1,146 @@ +/* + Copyright 2022-2025 Kyndryl, All Rights Reserved + + 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. + */ + +package com.ibm.hybrid.cloud.sample.stocktrader.trader; + +import com.ibm.hybrid.cloud.sample.stocktrader.trader.client.BrokerClient; +import com.ibm.hybrid.cloud.sample.stocktrader.trader.json.Sentiment; + +import java.io.IOException; +import java.util.logging.Logger; + +import jakarta.inject.Inject; +import jakarta.servlet.annotation.HttpConstraint; +import jakarta.servlet.annotation.ServletSecurity; +import jakarta.servlet.annotation.WebServlet; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +//mpJWT 1.0 +import org.eclipse.microprofile.jwt.JsonWebToken; + +//mpRestClient 1.0 +import org.eclipse.microprofile.rest.client.inject.RestClient; + +/** + * Servlet implementation class SentimentProxy + * + * Proxies sentiment API calls from the browser to the broker service. + * The broker service then calls the sentiment microservice. + * Flow: Browser → Trader (this servlet) → Broker → Sentiment API + */ +@WebServlet(description = "Sentiment API Proxy servlet via Broker", urlPatterns = { "/sentiment/*" }) +@ServletSecurity(@HttpConstraint(rolesAllowed = { "StockTrader" })) +public class SentimentProxy extends HttpServlet { + private static final long serialVersionUID = 1L; + private static Logger logger = Logger.getLogger(SentimentProxy.class.getName()); + + private static Utilities utilities = null; + + private @Inject @RestClient BrokerClient brokerClient; + private @Inject JsonWebToken jwt; + + /** + * @see HttpServlet#HttpServlet() + */ + public SentimentProxy() { + super(); + + if (utilities == null) { + utilities = new Utilities(logger); + } + } + + /** + * @see HttpServlet#doGet(HttpServletRequest request, HttpServletResponse response) + */ + protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException { + // Extract symbol from path: /trader/sentiment/AAPL -> AAPL + String pathInfo = request.getPathInfo(); + if (pathInfo == null || pathInfo.length() <= 1) { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + response.setContentType("application/json"); + response.getWriter().write("{\"error\":\"Stock symbol is required\"}"); + return; + } + + // Remove leading slash + String symbol = pathInfo.substring(1); + + logger.fine("Proxying sentiment request for " + symbol + " via broker service"); + + try { + // Call broker service to get sentiment (broker will call sentiment API) + Sentiment sentiment = brokerClient.getSentiment( + utilities.getAuthHeader(jwt, request), + symbol + ); + + // Set CORS headers to allow browser access + response.setHeader("Access-Control-Allow-Origin", "*"); + response.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS"); + response.setHeader("Access-Control-Allow-Headers", "Content-Type"); + + response.setContentType("application/json"); + + if (sentiment != null) { + // Convert Sentiment object to JSON + response.setStatus(HttpServletResponse.SC_OK); + String dominantSentiment = sentiment.getDominantSentiment(); + int sourcesAnalyzed = sentiment.getSourcesAnalyzed(); + logger.info("SentimentProxy: Received sentiment for " + symbol + + " - dominant: " + dominantSentiment + + ", sources: " + sourcesAnalyzed + + ", positive: " + sentiment.getPositive() + + ", negative: " + sentiment.getNegative() + + ", neutral: " + sentiment.getNeutral()); + response.getWriter().write("{" + + "\"symbol\":\"" + sentiment.getSymbol() + "\"," + + "\"positive\":" + sentiment.getPositive() + "," + + "\"negative\":" + sentiment.getNegative() + "," + + "\"neutral\":" + sentiment.getNeutral() + "," + + "\"net_sentiment\":" + sentiment.getNetSentiment() + "," + + "\"dominant_sentiment\":\"" + (dominantSentiment != null ? dominantSentiment : "null") + "\"," + + "\"timestamp\":\"" + (sentiment.getTimestamp() != null ? sentiment.getTimestamp() : "") + "\"," + + "\"sources_analyzed\":" + sourcesAnalyzed + + "}"); + logger.fine("Successfully proxied sentiment response for " + symbol); + } else { + // Sentiment service not available or returned null + response.setStatus(HttpServletResponse.SC_SERVICE_UNAVAILABLE); + response.getWriter().write("{\"error\":\"Sentiment service is not available\"}"); + logger.warning("Sentiment service returned null for " + symbol); + } + } catch (Exception e) { + logger.warning("Error proxying sentiment request for " + symbol + ": " + e.getMessage()); + response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + response.setContentType("application/json"); + response.getWriter().write("{\"error\":\"Failed to fetch sentiment: " + e.getMessage() + "\"}"); + } + } + + /** + * Handle OPTIONS request for CORS preflight + */ + protected void doOptions(HttpServletRequest request, HttpServletResponse response) { + response.setHeader("Access-Control-Allow-Origin", "*"); + response.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS"); + response.setHeader("Access-Control-Allow-Headers", "Content-Type"); + response.setStatus(HttpServletResponse.SC_OK); + } +} + diff --git a/src/main/java/com/ibm/hybrid/cloud/sample/stocktrader/trader/Utilities.java b/src/main/java/com/ibm/hybrid/cloud/sample/stocktrader/trader/Utilities.java index ddbe60bd..40bb7d2c 100644 --- a/src/main/java/com/ibm/hybrid/cloud/sample/stocktrader/trader/Utilities.java +++ b/src/main/java/com/ibm/hybrid/cloud/sample/stocktrader/trader/Utilities.java @@ -61,6 +61,8 @@ public class Utilities { private static String whiteLabelLoginMessage = "Login to StockTrader"; private static boolean useS3 = false; + private static boolean sentimentEnabled = false; + private static String sentimentDashboardUrl = null; private static AmazonS3 s3 = null; private static String s3Bucket = null; @@ -86,6 +88,15 @@ public class Utilities { useS3 = Boolean.parseBoolean(System.getenv("S3_ENABLED")); logger.info("useS3: "+useS3); + sentimentEnabled = Boolean.parseBoolean(System.getenv("SENTIMENT_ENABLED")); + logger.info("Sentiment enabled: " + sentimentEnabled); + + String sentimentDashboardUrlFromEnv = System.getenv("SENTIMENT_DASHBOARD_URL"); + if (sentimentDashboardUrlFromEnv != null && !sentimentDashboardUrlFromEnv.isEmpty()) { + sentimentDashboardUrl = sentimentDashboardUrlFromEnv; + logger.info("Sentiment dashboard URL: " + sentimentDashboardUrl); + } + String mpUrlPropName = BrokerClient.class.getName() + "/mp-rest/url"; String brokerURL = System.getenv("BROKER_URL"); if ((brokerURL != null) && !brokerURL.isEmpty()) { @@ -164,6 +175,14 @@ public static String getLoginMessage() { return whiteLabelLoginMessage; } + public static boolean getSentimentEnabled() { + return sentimentEnabled; + } + + public static String getSentimentDashboardUrl() { + return sentimentDashboardUrl; + } + public static void logToS3(String key, Object document) { if (useS3) try { if (s3 == null) { diff --git a/src/main/java/com/ibm/hybrid/cloud/sample/stocktrader/trader/client/BrokerClient.java b/src/main/java/com/ibm/hybrid/cloud/sample/stocktrader/trader/client/BrokerClient.java index 306c28bf..1e6a6d65 100644 --- a/src/main/java/com/ibm/hybrid/cloud/sample/stocktrader/trader/client/BrokerClient.java +++ b/src/main/java/com/ibm/hybrid/cloud/sample/stocktrader/trader/client/BrokerClient.java @@ -84,6 +84,12 @@ public interface BrokerClient { @WithSpan(kind = SpanKind.CLIENT, value="BrokerClient.getReturnOnInvestment") public String getReturnOnInvestment(@HeaderParam("Authorization") String jwt, @PathParam("owner") String owner); + @GET + @Path("/sentiment/{symbol}") + @Produces(MediaType.APPLICATION_JSON) + @WithSpan(kind = SpanKind.CLIENT, value="BrokerClient.getSentiment") + public com.ibm.hybrid.cloud.sample.stocktrader.trader.json.Sentiment getSentiment(@HeaderParam("Authorization") String jwt, @PathParam("symbol") String symbol); + @POST @Path("/{owner}/feedback") @Consumes("application/json") diff --git a/src/main/java/com/ibm/hybrid/cloud/sample/stocktrader/trader/json/Sentiment.java b/src/main/java/com/ibm/hybrid/cloud/sample/stocktrader/trader/json/Sentiment.java new file mode 100644 index 00000000..b3b09a4e --- /dev/null +++ b/src/main/java/com/ibm/hybrid/cloud/sample/stocktrader/trader/json/Sentiment.java @@ -0,0 +1,106 @@ +/* + Copyright 2017-2021 IBM Corp All Rights Reserved + Copyright 2022-2025 Kyndryl, All Rights Reserved + + 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. + */ + +package com.ibm.hybrid.cloud.sample.stocktrader.trader.json; + +import jakarta.json.bind.annotation.JsonbProperty; + +/** JSON-B POJO class representing a Sentiment JSON object from the Sentiment Analysis API */ +public class Sentiment { + private String symbol; + private double positive; + private double negative; + private double neutral; + private double netSentiment; + private String dominantSentiment; + private String timestamp; + private int sourcesAnalyzed; + + public Sentiment() { //default constructor + } + + public String getSymbol() { + return symbol; + } + + public void setSymbol(String symbol) { + this.symbol = symbol; + } + + public double getPositive() { + return positive; + } + + public void setPositive(double positive) { + this.positive = positive; + } + + public double getNegative() { + return negative; + } + + public void setNegative(double negative) { + this.negative = negative; + } + + public double getNeutral() { + return neutral; + } + + public void setNeutral(double neutral) { + this.neutral = neutral; + } + + @JsonbProperty("net_sentiment") + public double getNetSentiment() { + return netSentiment; + } + + @JsonbProperty("net_sentiment") + public void setNetSentiment(double netSentiment) { + this.netSentiment = netSentiment; + } + + @JsonbProperty("dominant_sentiment") + public String getDominantSentiment() { + return dominantSentiment; + } + + @JsonbProperty("dominant_sentiment") + public void setDominantSentiment(String dominantSentiment) { + this.dominantSentiment = dominantSentiment; + } + + public String getTimestamp() { + return timestamp; + } + + public void setTimestamp(String timestamp) { + this.timestamp = timestamp; + } + + @JsonbProperty("sources_analyzed") + public int getSourcesAnalyzed() { + return sourcesAnalyzed; + } + + @JsonbProperty("sources_analyzed") + public void setSourcesAnalyzed(int sourcesAnalyzed) { + this.sourcesAnalyzed = sourcesAnalyzed; + } +} + diff --git a/src/main/webapp/WEB-INF/jsps/addStock.jsp b/src/main/webapp/WEB-INF/jsps/addStock.jsp index e50cc17c..895e614f 100644 --- a/src/main/webapp/WEB-INF/jsps/addStock.jsp +++ b/src/main/webapp/WEB-INF/jsps/addStock.jsp @@ -101,6 +101,49 @@ Utilities.getFooterImage(); %> />
Please enter a stock symbol.
+ +