Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .github/workflows/build-and-push-ghcr.yml
Original file line number Diff line number Diff line change
@@ -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 }}


2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
```
Original file line number Diff line number Diff line change
@@ -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);
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ public class Utilities {
private static String whiteLabelLoginMessage = "Login to <span class=\"brand-main\">Stock</span><span class=\"brand-accent\">Trader</span>";

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;

Expand All @@ -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()) {
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}

Loading
Loading