From 329d6a531ebe5aa330aacb583738a9282c9d2515 Mon Sep 17 00:00:00 2001 From: slayerjain Date: Wed, 22 Apr 2026 11:35:34 +0530 Subject: [PATCH 1/3] feat(sap-demo-java): add Customer 360 SAP S/4HANA + Postgres sample A Spring Boot "Customer 360" API that exercises two integrations at once: - Outbound HTTPS to SAP S/4HANA Cloud Business Accelerator Hub (sandbox API Business Partner + Sales Order OData endpoints) - Local PostgreSQL persistence for customer tags, notes, and audit events The sample covers the full keploy flow (record + replay) against both egress surfaces simultaneously, and is structured to be driven from a Tosca-style flow script. It ships three run shapes: - docker-compose.yml: single-host demo (app + postgres) - k8s/ + kind-config.yaml + deploy_kind.sh: local kind cluster deploy - pom.xml mvn spring-boot:run: plain JVM Secrets handling: - .env is gitignored; only .env.example is committed (empty SAP_API_KEY) - k8s/secret.yaml is gitignored; only secret.yaml.example is committed (placeholder ) - Users populate both from their own SAP Business Accelerator Hub key Included flow artefacts: - demo_script.sh: end-to-end curl walkthrough - run_flow.sh / simulate_tosca_flow.sh: scripted user journeys - keploy.yml: recording config (empty globalNoise, no recorded data) --- README.md | 1 + sap-demo-java/.dockerignore | 20 + sap-demo-java/.env.example | 16 + sap-demo-java/.gitignore | 21 ++ sap-demo-java/Dockerfile | 22 ++ sap-demo-java/README.md | 302 +++++++++++++++ sap-demo-java/demo_script.sh | 188 ++++++++++ sap-demo-java/deploy_kind.sh | 350 ++++++++++++++++++ sap-demo-java/docker-compose.yml | 44 +++ sap-demo-java/k8s/configmap.yaml | 14 + sap-demo-java/k8s/deployment.yaml | 113 ++++++ sap-demo-java/k8s/ingress.yaml | 42 +++ sap-demo-java/k8s/namespace.yaml | 9 + sap-demo-java/k8s/postgres.yaml | 108 ++++++ sap-demo-java/k8s/secret.yaml.example | 23 ++ sap-demo-java/k8s/service.yaml | 19 + sap-demo-java/keploy.yml | 122 ++++++ sap-demo-java/kind-config.yaml | 17 + sap-demo-java/pom.xml | 144 +++++++ sap-demo-java/run_flow.sh | 276 ++++++++++++++ sap-demo-java/simulate_tosca_flow.sh | 127 +++++++ .../customer360/Customer360Application.java | 47 +++ .../customer360/config/SapClientConfig.java | 135 +++++++ .../customer360/model/BusinessPartner.java | 99 +++++ .../model/BusinessPartnerAddress.java | 75 ++++ .../model/BusinessPartnerRole.java | 38 ++ .../customer360/model/Customer360View.java | 64 ++++ .../customer360/model/CustomerSummary.java | 52 +++ .../sap/customer360/model/NoteRequest.java | 24 ++ .../model/ODataCollectionResponse.java | 75 ++++ .../model/ODataEntityResponse.java | 35 ++ .../customer360/model/ProblemResponse.java | 57 +++ .../sap/customer360/model/TagRequest.java | 26 ++ .../customer360/persistence/AuditEvent.java | 65 ++++ .../customer360/persistence/CustomerNote.java | 55 +++ .../customer360/persistence/CustomerTag.java | 58 +++ .../repository/AuditEventRepository.java | 15 + .../repository/CustomerNoteRepository.java | 15 + .../repository/CustomerTagRepository.java | 24 ++ .../customer360/sap/CorrelationIdFilter.java | 48 +++ .../sap/CorrelationIdInterceptor.java | 39 ++ .../sap/customer360/sap/SapApiException.java | 68 ++++ .../sap/SapBusinessPartnerClient.java | 250 +++++++++++++ .../sap/customer360/service/AuditService.java | 47 +++ .../service/Customer360AggregatorService.java | 181 +++++++++ .../customer360/service/CustomerService.java | 46 +++ .../sap/customer360/service/NoteService.java | 39 ++ .../sap/customer360/service/TagService.java | 59 +++ .../sap/customer360/web/AuditController.java | 41 ++ .../customer360/web/CustomerController.java | 100 +++++ .../web/GlobalExceptionHandler.java | 135 +++++++ .../sap/customer360/web/NoteController.java | 70 ++++ .../sap/customer360/web/TagController.java | 80 ++++ .../src/main/resources/application.yml | 177 +++++++++ .../db/migration/V1__init_schema.sql | 41 ++ .../src/main/resources/logback-spring.xml | 38 ++ .../Customer360ApplicationTests.java | 26 ++ 57 files changed, 4422 insertions(+) create mode 100644 sap-demo-java/.dockerignore create mode 100644 sap-demo-java/.env.example create mode 100644 sap-demo-java/.gitignore create mode 100644 sap-demo-java/Dockerfile create mode 100644 sap-demo-java/README.md create mode 100755 sap-demo-java/demo_script.sh create mode 100755 sap-demo-java/deploy_kind.sh create mode 100644 sap-demo-java/docker-compose.yml create mode 100644 sap-demo-java/k8s/configmap.yaml create mode 100644 sap-demo-java/k8s/deployment.yaml create mode 100644 sap-demo-java/k8s/ingress.yaml create mode 100644 sap-demo-java/k8s/namespace.yaml create mode 100644 sap-demo-java/k8s/postgres.yaml create mode 100644 sap-demo-java/k8s/secret.yaml.example create mode 100644 sap-demo-java/k8s/service.yaml create mode 100755 sap-demo-java/keploy.yml create mode 100644 sap-demo-java/kind-config.yaml create mode 100644 sap-demo-java/pom.xml create mode 100755 sap-demo-java/run_flow.sh create mode 100755 sap-demo-java/simulate_tosca_flow.sh create mode 100644 sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/Customer360Application.java create mode 100644 sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/config/SapClientConfig.java create mode 100644 sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/model/BusinessPartner.java create mode 100644 sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/model/BusinessPartnerAddress.java create mode 100644 sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/model/BusinessPartnerRole.java create mode 100644 sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/model/Customer360View.java create mode 100644 sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/model/CustomerSummary.java create mode 100644 sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/model/NoteRequest.java create mode 100644 sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/model/ODataCollectionResponse.java create mode 100644 sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/model/ODataEntityResponse.java create mode 100644 sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/model/ProblemResponse.java create mode 100644 sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/model/TagRequest.java create mode 100644 sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/persistence/AuditEvent.java create mode 100644 sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/persistence/CustomerNote.java create mode 100644 sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/persistence/CustomerTag.java create mode 100644 sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/repository/AuditEventRepository.java create mode 100644 sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/repository/CustomerNoteRepository.java create mode 100644 sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/repository/CustomerTagRepository.java create mode 100644 sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/sap/CorrelationIdFilter.java create mode 100644 sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/sap/CorrelationIdInterceptor.java create mode 100644 sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/sap/SapApiException.java create mode 100644 sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/sap/SapBusinessPartnerClient.java create mode 100644 sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/service/AuditService.java create mode 100644 sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/service/Customer360AggregatorService.java create mode 100644 sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/service/CustomerService.java create mode 100644 sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/service/NoteService.java create mode 100644 sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/service/TagService.java create mode 100644 sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/web/AuditController.java create mode 100644 sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/web/CustomerController.java create mode 100644 sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/web/GlobalExceptionHandler.java create mode 100644 sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/web/NoteController.java create mode 100644 sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/web/TagController.java create mode 100644 sap-demo-java/src/main/resources/application.yml create mode 100644 sap-demo-java/src/main/resources/db/migration/V1__init_schema.sql create mode 100644 sap-demo-java/src/main/resources/logback-spring.xml create mode 100644 sap-demo-java/src/test/java/com/tricentisdemo/sap/customer360/Customer360ApplicationTests.java diff --git a/README.md b/README.md index 6c2e78f2..f0b5164b 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ This repo contains the sample for [Keploy's](https://keploy.io) Java Application 3. [User Manager](https://github.com/keploy/samples-java/tree/main/user-manager) - A sample User-Manager app to test Keploy integration capabilities using SpringBoot and MongoDB. 4. [Springboot Postgres GraphQL](https://github.com/keploy/samples-java/tree/main/spring-boot-postgres-graphql) - This is a Spring Boot application implementing a GraphQL service to handle requests related to books and authors. 5. [Springboot PetClinic](https://github.com/keploy/samples-java/tree/main/spring-petclinic) - This is a Pet Clinic app where you can record testcases and mocks by interacting with the UI, and then test them using Keploy. +6. [SAP Demo (Customer 360)](https://github.com/keploy/samples-java/tree/main/sap-demo-java) - A Spring Boot "Customer 360" API that fronts SAP S/4HANA Cloud (Business Partner + Sales Order OData) and a local PostgreSQL store. Includes docker-compose, a kind-based k8s deploy, and Tosca-style flow scripts suitable for recording end-to-end Keploy testcases against PostgreSQL + outbound SAP HTTPS. ## Community Support ❤️ diff --git a/sap-demo-java/.dockerignore b/sap-demo-java/.dockerignore new file mode 100644 index 00000000..f1b84a24 --- /dev/null +++ b/sap-demo-java/.dockerignore @@ -0,0 +1,20 @@ +.git +.gitignore +.dockerignore +.env +.env.example +.idea +.vscode +# Allow only the final fat jar into the build context (built by mvn package). +# Everything else under target/ is excluded. +target/* +!target/customer360.jar +keploy/ +keploy.yml +k8s/secret.yaml +*.log +/tmp +README.md +demo_script.sh +simulate_tosca_flow.sh +deploy_kind.sh diff --git a/sap-demo-java/.env.example b/sap-demo-java/.env.example new file mode 100644 index 00000000..5169da59 --- /dev/null +++ b/sap-demo-java/.env.example @@ -0,0 +1,16 @@ +# Copy to .env and fill in the SAP API key. +# .env is gitignored. Never commit real credentials. + +# SAP Business Accelerator Hub sandbox API key. +# Get a free one at https://api.sap.com → any API → "Show API Key". +SAP_API_KEY= + +# Optional — for real BTP tenant (OAuth2 xsuaa bearer token). +# If set, takes precedence over SAP_API_KEY. +SAP_BEARER_TOKEN= + +# Override these if you point at a non-sandbox tenant. +SAP_API_BASE_URL=https://sandbox.api.sap.com/s4hanacloud + +# Local dev port. Ignored when running in kind (NodePort 30080 is the entry). +SERVER_PORT=8080 diff --git a/sap-demo-java/.gitignore b/sap-demo-java/.gitignore new file mode 100644 index 00000000..cda2660e --- /dev/null +++ b/sap-demo-java/.gitignore @@ -0,0 +1,21 @@ +# Build artefacts +target/ +*.jar +!.mvn/wrapper/maven-wrapper.jar +.idea/ +.vscode/ +*.iml + +# Secrets +.env +k8s/secret.yaml + +# Keploy captures (these ARE the artefact — commit deliberately, not auto) +# Uncomment the next two lines if you prefer to gitignore them: +# keploy/ +# keploy.yml + +# OS / editor noise +.DS_Store +*.swp +*.swo diff --git a/sap-demo-java/Dockerfile b/sap-demo-java/Dockerfile new file mode 100644 index 00000000..60adec38 --- /dev/null +++ b/sap-demo-java/Dockerfile @@ -0,0 +1,22 @@ +# syntax=docker/dockerfile:1.7 +# +# Uses amazoncorretto:25 — Amazon Linux 2023-based, minimal, JDK-ready. +# The jar is built outside Docker (mvn package); this image just packages it +# and runs it. K8s securityContext enforces the non-root runtime UID (1001). +# +# Docker Hub pull rate limits are avoided by relying on the locally cached +# amazoncorretto:25 image. + +FROM amazoncorretto:25 + +WORKDIR /app + +# Make /app world-readable so the K8s-enforced UID 1001 can read the jar. +COPY target/customer360.jar /app/customer360.jar +RUN chmod 755 /app && chmod 644 /app/customer360.jar + +ENV JAVA_OPTS="-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError -Djava.security.egd=file:/dev/./urandom" + +EXPOSE 8080 + +ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar /app/customer360.jar"] diff --git a/sap-demo-java/README.md b/sap-demo-java/README.md new file mode 100644 index 00000000..e06cce00 --- /dev/null +++ b/sap-demo-java/README.md @@ -0,0 +1,302 @@ +# Customer 360 — SAP Integration Service (Java / Spring Boot / K8s) + +Enterprise-grade reference implementation of the most common SAP integration +pattern in RISE with SAP landscapes: a **Customer 360 aggregator** that +fans out to multiple S/4HANA Business Partner OData endpoints and composes +them into a unified view for downstream CRM / portal / analytics consumers. + +Built for the Tricentis evaluation demo. Deliberately looks like code a +Tricentis customer would already recognise: Spring Boot 3, Java 21, +Resilience4j, Actuator, OpenAPI, correlation-id propagation, RFC 7807 +problem responses, multi-stage Docker build, production-grade Kubernetes +manifests, kind-ready. + +--- + +## The one-line pitch + +> Tosca drives Fiori; Keploy records that **one inbound click fans out to +> three parallel SAP OData calls** that Tosca cannot see. + +A typical `GET /api/v1/customers/202/360` produces: + +``` + inbound (1) outbound SAP OData (3, parallel) + ───────────── ───────────────────────────────────────── + /360 ────► /A_BusinessPartner('202') + /A_BusinessPartnerAddress?$filter=…'202' + /A_BusinessPartnerRole?$filter=…'202' +``` + +One Keploy eBPF probe captures every call on that flow. Replay them with +SAP unreachable and the whole 360 aggregation still works, deterministically. + +--- + +## Architecture + +``` + ┌──────────────────────────────────┐ + downstream consumer │ customer360 (this service) │ + (CRM, portal, analytics) ───┤ │ + │ ┌───── CustomerController ───┐ │ + │ │ /customers, /{id}, │ │ + │ │ /{id}/360, /count │ │ + │ └────────┬───────────────────┘ │ + │ │ │ + │ ┌────────▼──────────┐ │ + │ │ Customer360Aggr. │ ◄── parallel fan-out + │ │ + CustomerService │ │ + │ └────────┬──────────┘ │ + │ │ │ + │ ┌────────▼──────────────────┐ │ + │ │ SapBusinessPartnerClient │ │ + │ │ + Resilience4j retry/cb │ │ + │ │ + RestTemplate │ │ + │ └────────┬──────────────────┘ │ + └───────────┼────────────────────────┘ + │ HTTPS + APIKey/Bearer + ▼ + https://sandbox.api.sap.com/s4hanacloud + (or real BTP tenant) +``` + +## REST surface + +| Method | Path | What it does | +|--------|--------------------------------|---------------------------------------------| +| GET | /api/v1/customers | Paged list of customer summaries | +| GET | /api/v1/customers/count | Total partner count (KPI tile) | +| GET | /api/v1/customers/{id} | Single business partner master data | +| GET | **/api/v1/customers/{id}/360** | **Aggregated 360 view — fans out to 3 SAP calls in parallel** | +| GET | /actuator/health | K8s liveness / readiness | +| GET | /actuator/prometheus | Metrics | +| GET | /swagger-ui.html | OpenAPI UI | +| GET | /v3/api-docs | OpenAPI 3 spec | + +Swagger UI inside the kind cluster: `http://localhost:30080/swagger-ui.html`. + +--- + +## Quick start (kind cluster) + +```bash +cd /home/shubham/tricentis/sap_testing/sap_demo_java + +# 1. One-time: drop your SAP API key into .env +cp .env.example .env +$EDITOR .env # paste SAP_API_KEY + +# 2. Stand everything up: kind cluster + build + load + apply +./deploy_kind.sh + +# 3. Exercise the deployed service +./demo_script.sh exercise + +# 4. Look inside — preferred URL via Ingress on port 80 +curl -s http://customer360.localtest.me/actuator/health | jq . +curl -s http://customer360.localtest.me/api/v1/customers/count | jq . +curl -s http://customer360.localtest.me/api/v1/customers/202/360 | jq '.partner.BusinessPartnerFullName, (.addresses | length), (.roles | length)' + +# …or fall back to NodePort 30080 (also works on the default kind-config) +curl -s http://localhost:30080/actuator/health | jq . + +# 5. Stream logs (structured JSON with correlationId) +./deploy_kind.sh logs + +# 6. Tear down +./deploy_kind.sh destroy +``` + +The `./deploy_kind.sh` script is idempotent. Re-run after code changes to +rebuild and roll the deployment. + +### Targeting a non-default cluster + +The script defaults to a cluster named `sap-demo`, but you can point it at +any kind cluster via `--cluster NAME` / `-c NAME` / the `KIND_CLUSTER` +env var: + +```bash +# Flag form +./deploy_kind.sh --cluster my-existing-cluster apply +./deploy_kind.sh -c keploy-bug2 apply + +# Env-var form +KIND_CLUSTER=my-existing-cluster ./deploy_kind.sh apply + +# Flag wins over env var if both are set. +``` + +Useful when deploying into a cluster that already hosts Keploy's +`k8s-proxy` (see `../k8s-proxy/README.md`). The script will skip cluster +creation if the named cluster already exists. If the existing cluster +wasn't created from `kind-config.yaml`, it probably doesn't have port +`30080` mapped to the host — the script will warn and suggest +`kubectl port-forward` instead. + +--- + +## Recording with Keploy — two modes + +### Mode A — local binary + eBPF on the host (simplest) + +Same mechanic as `sap_demo_A`. Run the JVM directly on Linux; Keploy attaches +eBPF probes to the Go/Java/Node process on the host. + +```bash +./demo_script.sh record-local # builds, starts under keploy record, exercises +./demo_script.sh test-local # replays +./demo_script.sh offline-test # replays with SAP blackholed in /etc/hosts +``` + +Captured artefact: `keploy/test-set-0/tests/*.yaml` + `keploy/test-set-0/mocks.yaml`. + +### Mode B — Keploy inside the kind cluster (production-shaped) + +The `keploy.io/record: "enabled"` annotation on the Deployment marks this pod +as a candidate for live recording via the `k8s-proxy` Helm chart (see +`../k8s-proxy/README.md`). The `Namespace` is labelled `keploy.io/enabled: "true"` +for the same reason. + +Summary — full steps are in `./demo_script.sh record-k8s`: + +```bash +# 1. deploy the app (this repo) +./deploy_kind.sh + +# 2. install k8s-proxy alongside (from ../k8s-proxy) +helm upgrade --install k8s-proxy ../k8s-proxy/charts/k8s-proxy \ + --namespace keploy --create-namespace + +# 3. drive traffic — tests stream back to the enterprise-ui dashboard +./demo_script.sh exercise +``` + +Mode B demonstrates the story that matters to an enterprise buyer: **Keploy +works in Kubernetes, not just on a developer laptop.** + +--- + +## The two-terminal "Tosca sidecar" demo + +Terminal 1 — Keploy recording the app: +```bash +./demo_script.sh record-local # just the record half — Ctrl+C when done +``` + +Terminal 2 — the narrated Tosca-driven flow: +```bash +./simulate_tosca_flow.sh --host http://localhost:8080 +# or, if the app is in kind: +./simulate_tosca_flow.sh --host http://localhost:30080 +``` + +The narrator script logs what Tosca would "click" in the Fiori UI and +what Keploy captures underneath. The 360 step in particular is the pitch — +**one inbound click, three outbound SAP OData calls**, all recorded. + +--- + +## Configuration + +All runtime config is externalised. The ConfigMap (`k8s/configmap.yaml`) and +Secret (`k8s/secret.yaml`, from `.example`) are the K8s sources of truth. +For local / compose runs, `.env` takes their place. + +| Env var | Default | Notes | +|----------------------|----------------------------------------------|---------------------------------------| +| `SAP_API_BASE_URL` | `https://sandbox.api.sap.com/s4hanacloud` | Upstream SAP tenant | +| `SAP_API_KEY` | *(empty)* | Sandbox API key | +| `SAP_BEARER_TOKEN` | *(empty)* | Preferred if set (real BTP tenant) | +| `SERVER_PORT` | `8080` | Container listen port | +| `SPRING_PROFILES_ACTIVE` | `default` locally, `kubernetes` in K8s | Toggles JSON log formatter | + +Resilience4j retry / circuit-breaker settings live in `application.yml` +under `resilience4j.*`. Defaults: 3 attempts, exponential backoff, CB opens +at 60% failure over 20 calls, 30s open-state cool-down. + +--- + +## Files + +| Path | Purpose | +|---|---| +| `pom.xml` | Maven — Spring Boot 3, Java 21, Resilience4j, Actuator, SpringDoc | +| `src/main/java/.../Customer360Application.java` | Spring Boot entry + OpenAPI metadata | +| `src/main/java/.../config/SapClientConfig.java` | `RestTemplate` + auth / encoding / correlation interceptors + fan-out executor | +| `src/main/java/.../web/CustomerController.java` | REST endpoints | +| `src/main/java/.../web/GlobalExceptionHandler.java` | RFC 7807 problem responses | +| `src/main/java/.../service/CustomerService.java` | Simple lookups | +| `src/main/java/.../service/Customer360AggregatorService.java` | **Fan-out aggregator — the demo money shot** | +| `src/main/java/.../sap/SapBusinessPartnerClient.java` | Low-level SAP gateway with retry / CB | +| `src/main/java/.../sap/CorrelationIdFilter.java` | Inbound correlation-id seeder | +| `src/main/java/.../sap/CorrelationIdInterceptor.java` | Outbound correlation-id propagator | +| `src/main/java/.../sap/SapApiException.java` | SAP-specific exception | +| `src/main/java/.../model/*.java` | DTOs: BusinessPartner, Address, Role, Customer360View, … | +| `src/main/resources/application.yml` | Externalised config | +| `src/main/resources/logback-spring.xml` | Console (dev) + JSON (kubernetes) appenders | +| `Dockerfile` | Multi-stage build, non-root runtime, Spring Boot layers | +| `docker-compose.yml` | Local dev without kind | +| `kind-config.yaml` | Ingress-ready kind cluster (host 80/443 + 30080 mapped, `ingress-ready=true` label) | +| `k8s/namespace.yaml` | Namespace with `keploy.io/enabled: "true"` marker | +| `k8s/configmap.yaml` | Non-secret runtime config | +| `k8s/secret.yaml.example` | Template for `SAP_API_KEY` secret | +| `k8s/deployment.yaml` | Liveness/readiness probes, resource limits, security context | +| `k8s/service.yaml` | NodePort 30080 (works standalone, also the Ingress backend) | +| `k8s/ingress.yaml` | Ingress at `customer360.localtest.me` + catch-all at `localhost` | +| `deploy_kind.sh` | One-shot cluster + build + load + apply | +| `demo_script.sh` | Record / replay / offline-test harness | +| `simulate_tosca_flow.sh` | Narrated Tosca-driven Fiori flow | + +--- + +## What makes this "enterprise-grade" (and why Tricentis folks will recognise it) + +- **Spring Boot 3 + Java 21** — the default stack in most RISE customers +- **Layered: controller → service → SAP client** — classic separation +- **Resilience4j retry + circuit breaker** on every SAP call +- **RFC 7807 problem responses** with `X-Upstream-Status` for SAP diagnostics +- **Correlation-id propagation** end-to-end (inbound filter + outbound interceptor) +- **Structured JSON logs** in the `kubernetes` profile +- **Actuator probes** wired to K8s liveness + readiness, with circuit-breaker health included +- **Prometheus metrics** with request histograms / percentiles +- **OpenAPI 3 / Swagger UI** for API discovery +- **Non-root Docker runtime**, `readOnlyRootFilesystem`, capabilities dropped +- **Multi-stage Spring Boot layered image** for fast rebuilds +- **Graceful shutdown** with 30s termination grace period for rolling deploys +- **NodePort 30080** aligned with the `k8s-proxy` convention used elsewhere in this repo + +--- + +## Troubleshooting + +| Symptom | Fix | +|---|---| +| `401 Unauthorized` from SAP | `SAP_API_KEY` missing or expired. Verify with `curl -H "APIKey: $SAP_API_KEY" https://sandbox.api.sap.com/s4hanacloud/sap/opu/odata/sap/API_BUSINESS_PARTNER/A_BusinessPartner?\$top=1` | +| `ImagePullBackOff` in kind | You forgot to `kind load docker-image customer360:local`. Run `./deploy_kind.sh build`. | +| `ErrImageNeverPull` | `imagePullPolicy: IfNotPresent` and image not loaded. Same fix. | +| Liveness probe failing at startup | 40s `startupProbe` grace should cover the JVM warm-up; if not, raise `failureThreshold` in `k8s/deployment.yaml`. | +| Circuit breaker stuck open | Check SAP is actually reachable; inspect `/actuator/health` → `components.circuitBreakers`. | +| `./deploy_kind.sh` says kind not found | Install with `go install sigs.k8s.io/kind@latest` or from the kind release page. | +| Rate-limited (429) from SAP sandbox | Sandbox is per-minute limited. Wait 60s, or switch to offline replay which never touches SAP. | +| Keploy recording on port 8080 | Runs the JVM; Keploy's default proxy/DNS ports are 16789/26789 — check `ss -ltnp` if you see bind errors. | + +--- + +## Extending this + +The obvious next moves for a real RISE customer: + +1. **OAuth2 client-credentials** (xsuaa) flow for production BTP tenants — + see `sap_demo_B` for the Go equivalent; the pattern is the same. +2. **Kafka producer** that publishes `customer.360.changed` events on + write flows — another recording surface for Keploy. +3. **Caching layer** (Redis) in front of SAP to absorb read bursts — + Keploy records those hits too. +4. **Ingress + mTLS** replacing NodePort for production topology. +5. **HelmChart** packaging to match the k8s-proxy deployment style. + +All of the above extend the integration graph, which extends Keploy's +demonstrable value surface. Nothing about them requires rewriting this +service — the layered design absorbs them cleanly. diff --git a/sap-demo-java/demo_script.sh b/sap-demo-java/demo_script.sh new file mode 100755 index 00000000..0419056e --- /dev/null +++ b/sap-demo-java/demo_script.sh @@ -0,0 +1,188 @@ +#!/usr/bin/env bash +# demo_script.sh — live demo harness for the Customer 360 service. +# +# Unlike sap_demo_A (Go, local), this demo runs the service in a kind cluster +# via Kubernetes — closer to the way a RISE customer would deploy a BTP +# extension. There are two recording modes: +# +# local — run Keploy on the host, record an out-of-cluster binary +# (same pattern as sap_demo_A; simplest, works anywhere) +# k8s — run Keploy inside the kind cluster as a sidecar on the pod +# (demonstrates the k8s-proxy integration story) +# +# Usage: +# ./demo_script.sh exercise # hit the deployed service with the sample flow +# ./demo_script.sh record-local # Keploy record against a local binary +# ./demo_script.sh test-local # replay captured mocks +# ./demo_script.sh offline-test # replay with SAP blackholed in /etc/hosts +# ./demo_script.sh record-k8s # (placeholder) record via k8s-proxy sidecar +# +# Prereqs for local mode: go>=1.25 or java>=21+mvn, keploy, sudo +# Prereqs for k8s mode: docker, kind, kubectl, app already deployed + +set -euo pipefail + +cd "$(dirname "$0")" + +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +BOLD='\033[1m' +NC='\033[0m' + +say() { printf "${BOLD}${GREEN}==> %s${NC}\n" "$*"; } +warn() { printf "${BOLD}${YELLOW}!! %s${NC}\n" "$*"; } +fail() { printf "${BOLD}${RED}XX %s${NC}\n" "$*"; } + +BASE_URL="${BASE_URL:-http://localhost:30080}" + +# ---------------------------------------------------------------------------- +# exercise_endpoints — the scripted "Tosca-style" business flow that drives +# the service while Keploy records underneath. 1 inbound → many SAP calls. +# ---------------------------------------------------------------------------- +exercise_endpoints() { + say "exercising Customer 360 endpoints against ${BASE_URL}" + say "each /360 call fans out to 3 parallel SAP OData GETs — Keploy captures all of them" + + echo " GET /actuator/health $(curl -sw '%{http_code}' -o /dev/null ${BASE_URL}/actuator/health)" + sleep 1 + echo " GET /api/v1/customers/count $(curl -sw '%{http_code}' -o /dev/null ${BASE_URL}/api/v1/customers/count)" + sleep 2 + echo " GET /api/v1/customers?top=3 $(curl -sw '%{http_code}' -o /dev/null ${BASE_URL}/api/v1/customers?top=3)" + sleep 2 + echo " GET /api/v1/customers/11 $(curl -sw '%{http_code}' -o /dev/null ${BASE_URL}/api/v1/customers/11)" + sleep 2 + echo " GET /api/v1/customers/202 $(curl -sw '%{http_code}' -o /dev/null ${BASE_URL}/api/v1/customers/202)" + sleep 2 + say "the money shot: aggregated 360 view (1 inbound, 3 parallel SAP OData calls)" + echo " GET /api/v1/customers/202/360 $(curl -sw '%{http_code}' -o /dev/null ${BASE_URL}/api/v1/customers/202/360)" + sleep 2 + echo " GET /api/v1/customers/11/360 $(curl -sw '%{http_code}' -o /dev/null ${BASE_URL}/api/v1/customers/11/360)" + sleep 2 +} + +ensure_built_locally() { + if [ ! -f target/customer360.jar ]; then + say "building customer360.jar (first run only)" + mvn -q -DskipTests package + fi +} + +load_env() { + if [ -f .env ]; then + set -a; source .env; set +a + fi +} + +start_local_keploy_record() { + ensure_built_locally + load_env + if [ -z "${SAP_API_KEY:-}" ]; then + fail "SAP_API_KEY missing — set it in .env or export before running" + exit 1 + fi + rm -rf keploy keploy.yml + say "starting service under keploy record (local JVM + eBPF attach)" + sudo -E keploy record -c "java -jar target/customer360.jar" > /tmp/keploy-record.log 2>&1 & + + for i in $(seq 1 60); do + if curl -s -o /dev/null http://localhost:8080/actuator/health 2>/dev/null; then + say "service ready after ${i}s" + return 0 + fi + sleep 1 + done + fail "service never became ready — see /tmp/keploy-record.log" + sudo pkill -INT -f "keploy record" 2>/dev/null || true + exit 1 +} + +stop_record() { + say "stopping keploy record" + sudo pkill -INT -f "keploy record" 2>/dev/null || true + for i in $(seq 1 45); do + if ! pgrep -f "keploy record" >/dev/null; then break; fi + sleep 1 + done + say "captured test cases:" + ls -1 keploy/test-set-0/tests/ 2>/dev/null | sed 's/^/ /' || true + if grep -lq "BusinessPartner" keploy/test-set-0/tests/*.yaml 2>/dev/null; then + say "confirmed real SAP data in captured YAML" + fi +} + +run_test() { + load_env + # Local replay against the local binary. BASE_URL is intentionally 8080 here + # (the Keploy test harness brings the app up on its original port). + BASE_URL="http://localhost:8080" + say "running keploy test — replays mocks without touching SAP" + sudo -E keploy test -c "java -jar target/customer360.jar" --delay 15 > /tmp/keploy-test.log 2>&1 & + TPID=$! + wait $TPID || true + + stripped=$(sed -E 's/\x1B\[[0-9;]*[mK]//g' /tmp/keploy-test.log) + pass_count=$(echo "$stripped" | awk '/Total test passed:/ {print $NF; exit}') + fail_count=$(echo "$stripped" | awk '/Total test failed:/ {print $NF; exit}') + total_count=$(echo "$stripped" | awk '/Total tests:/ {print $NF; exit}') + if [ -n "${pass_count:-}" ] && [ -n "${fail_count:-}" ] && [ "$fail_count" = "0" ]; then + printf "\n${BOLD}${GREEN}PASS${NC} %s/%s Keploy replays covered the 360 fan-out (3 SAP OData calls per /360) — no SAP traffic.\n\n" \ + "$pass_count" "${total_count:-$pass_count}" + return 0 + fi + fail "keploy test did not all pass — see /tmp/keploy-test.log" + tail -30 /tmp/keploy-test.log + return 1 +} + +offline_test() { + if ! grep -q "sandbox.api.sap.com" /etc/hosts; then + say "blackholing sandbox.api.sap.com in /etc/hosts (sudo)" + echo "127.0.0.1 sandbox.api.sap.com" | sudo tee -a /etc/hosts >/dev/null + trap 'sudo sed -i "/127.0.0.1 sandbox.api.sap.com/d" /etc/hosts' EXIT + fi + say "sanity: direct probe to SAP should now fail" + if curl -sS --max-time 5 -o /dev/null 'https://sandbox.api.sap.com/s4hanacloud/' 2>&1; then + warn "direct curl unexpectedly succeeded" + else + say "SAP unreachable (expected)" + fi + run_test +} + +record_k8s_stub() { + cat <<'EOF' +[PLACEHOLDER] Recording inside the kind cluster via Keploy's k8s-proxy is a +separate integration (see ../k8s-proxy/charts/k8s-proxy). The high-level steps: + + 1. Deploy the k8s-proxy Helm chart alongside the app: + helm upgrade --install k8s-proxy ../k8s-proxy/charts/k8s-proxy \ + --namespace keploy --create-namespace \ + --set keploy.apiServerUrl=http://host.docker.internal:8086 \ + --set keploy.clusterName=sap-demo + + 2. The k8s-proxy injects an eBPF-capable agent pod that can attach to the + customer360 pod via a shared mount-ns / PID-ns. + + 3. Drive traffic: ./demo_script.sh exercise + + 4. Keploy writes captured tests back through the api-server and they show + up at http://localhost:3000 in the enterprise UI. + +For the acquisition demo we typically use local recording (./demo_script.sh +record-local) — same mechanic, simpler to rehearse. +EOF +} + +cmd="${1:-exercise}" +case "$cmd" in + exercise) exercise_endpoints ;; + record-local) start_local_keploy_record; exercise_endpoints; stop_record ;; + test-local) run_test ;; + offline-test) offline_test ;; + record-k8s) record_k8s_stub ;; + *) + echo "usage: $0 [exercise|record-local|test-local|offline-test|record-k8s]" + exit 2 + ;; +esac diff --git a/sap-demo-java/deploy_kind.sh b/sap-demo-java/deploy_kind.sh new file mode 100755 index 00000000..0509ae1a --- /dev/null +++ b/sap-demo-java/deploy_kind.sh @@ -0,0 +1,350 @@ +#!/usr/bin/env bash +# deploy_kind.sh — one-shot helper to stand up the Customer 360 service +# inside a local kind cluster ready for Keploy recording. +# +# Usage: +# ./deploy_kind.sh [--cluster NAME | -c NAME] [SUBCOMMAND] +# KIND_CLUSTER=NAME ./deploy_kind.sh [SUBCOMMAND] +# +# The cluster name defaults to "sap-demo". Override via flag or env var to +# target an existing cluster (e.g. one that hosts the Keploy k8s-proxy). +# +# Subcommands: +# (none) / all — full pipeline: cluster + build + load + apply + wait +# cluster — only create the kind cluster (skipped if it exists) +# build — only (re)build the jar + docker image and kind-load it +# apply — only apply k8s manifests (assumes cluster + image ready) +# status — show pod, service and recent events +# logs — tail the app logs +# destroy — delete the kind cluster (and only that cluster) +# +# Examples: +# ./deploy_kind.sh # default: fresh sap-demo cluster +# KIND_CLUSTER=my-cluster ./deploy_kind.sh apply # apply into an existing cluster +# ./deploy_kind.sh -c keploy-bug2 apply # same via flag +# +# Prereqs: docker, kind, kubectl + +set -euo pipefail + +cd "$(dirname "$0")" + +# --- defaults --------------------------------------------------------------- +CLUSTER_NAME="${KIND_CLUSTER:-sap-demo}" +IMAGE_TAG="customer360:local" +NS="sap-demo" + +# --- flag parsing ----------------------------------------------------------- +# Accepts --cluster NAME / -c NAME anywhere in the arg list. +POSITIONAL=() +while [ $# -gt 0 ]; do + case "$1" in + -c|--cluster) + if [ -z "${2:-}" ]; then + echo "error: $1 requires a cluster name"; exit 2 + fi + CLUSTER_NAME="$2" + shift 2 + ;; + --cluster=*) + CLUSTER_NAME="${1#--cluster=}" + shift + ;; + -h|--help) + sed -n '1,28p' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + --) + shift; POSITIONAL+=("$@"); break ;; + *) + POSITIONAL+=("$1"); shift ;; + esac +done +set -- "${POSITIONAL[@]:-}" + +# --- colours ---------------------------------------------------------------- +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +BOLD='\033[1m' +NC='\033[0m' + +say() { printf "${BOLD}${GREEN}==> %s${NC}\n" "$*"; } +warn() { printf "${BOLD}${YELLOW}!! %s${NC}\n" "$*"; } +fail() { printf "${BOLD}${RED}XX %s${NC}\n" "$*"; } + +check_prereqs() { + for bin in docker kind kubectl; do + command -v "$bin" >/dev/null || { fail "$bin not found in PATH"; exit 1; } + done + say "target kind cluster: '${CLUSTER_NAME}' (kubectl context: kind-${CLUSTER_NAME})" +} + +cluster_exists() { + kind get clusters 2>/dev/null | grep -qx "${CLUSTER_NAME}" +} + +# Probe the control-plane container for which host→node port mappings exist. +# Purely advisory; we don't fail if something is missing. +check_port_mappings() { + local container="${CLUSTER_NAME}-control-plane" + if ! docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "${container}"; then + return 0 + fi + local ports + ports="$(docker port "${container}" 2>/dev/null || true)" + + HAS_HTTP=0 + HAS_NODEPORT=0 + echo "${ports}" | grep -q '80/tcp' && HAS_HTTP=1 + echo "${ports}" | grep -q '30080/tcp' && HAS_NODEPORT=1 + + if [ "${HAS_HTTP}" = 1 ]; then + say "host:80 → ${container}:80 mapping present (Ingress path available)" + else + warn "host:80 is NOT mapped on ${container} — Ingress won't be reachable on localhost" + fi + if [ "${HAS_NODEPORT}" = 1 ]; then + say "host:30080 → ${container}:30080 mapping present (NodePort path available)" + else + warn "host:30080 is NOT mapped on ${container} — NodePort won't be reachable on localhost" + fi + if [ "${HAS_HTTP}" = 0 ] && [ "${HAS_NODEPORT}" = 0 ]; then + warn "Neither access path is mapped. Fallback:" + warn " kubectl -n ${NS} port-forward svc/customer360 8080:8080" + fi +} + +# Install the kind-flavoured ingress-nginx controller if the target cluster +# has no IngressClass yet. Idempotent: skips if already present. +ensure_ingress_controller() { + kubectl config use-context "kind-${CLUSTER_NAME}" >/dev/null + if kubectl get ingressclass nginx >/dev/null 2>&1; then + say "ingress-nginx already present (IngressClass 'nginx' found)" + return 0 + fi + if kubectl get ingressclass -o name 2>/dev/null | head -n1 | grep -q .; then + warn "an IngressClass other than 'nginx' exists; skipping ingress-nginx install." + warn "edit k8s/ingress.yaml → ingressClassName to match if needed." + return 0 + fi + say "installing ingress-nginx (kind variant) — one-time per cluster" + local manifest="https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.11.3/deploy/static/provider/kind/deploy.yaml" + if ! kubectl apply -f "${manifest}"; then + warn "could not fetch ingress-nginx manifest from ${manifest}" + warn "install it manually, or stick to the NodePort URL at http://localhost:30080" + return 1 + fi + say "waiting for ingress-nginx controller pod to become ready" + kubectl wait --namespace ingress-nginx \ + --for=condition=ready pod \ + --selector=app.kubernetes.io/component=controller \ + --timeout=180s || warn "ingress-nginx didn't reach ready — Ingress URL may 503 until it does" + + # The controller pod being "ready" is not enough: the admission webhook + # service is a separate endpoint seeded by two short-lived Jobs + # (ingress-nginx-admission-create / -patch). If we apply the Ingress + # before those complete, the apiserver can't reach the validating + # webhook and the apply fails with a dial-tcp connection-refused error. + say "waiting for ingress-nginx admission jobs to complete" + kubectl wait --namespace ingress-nginx \ + --for=condition=complete job \ + --selector=app.kubernetes.io/component=admission-webhook \ + --timeout=120s || warn "admission jobs didn't complete — apply may need a retry" +} + +ensure_secret() { + if [ ! -f k8s/secret.yaml ]; then + warn "k8s/secret.yaml missing — creating from example" + if [ ! -f k8s/secret.yaml.example ]; then + fail "k8s/secret.yaml.example missing" + exit 1 + fi + if [ -f .env ] && grep -q '^SAP_API_KEY=' .env; then + KEY=$(grep '^SAP_API_KEY=' .env | cut -d= -f2- | tr -d '"'"'") + sed "s||${KEY}|" k8s/secret.yaml.example > k8s/secret.yaml + say "secret.yaml generated from .env" + else + fail "No SAP_API_KEY in .env and no k8s/secret.yaml — copy and edit k8s/secret.yaml.example" + exit 1 + fi + fi +} + +create_cluster() { + if cluster_exists; then + say "kind cluster '${CLUSTER_NAME}' already exists — reusing" + else + if [ "${CLUSTER_NAME}" != "sap-demo" ]; then + warn "cluster '${CLUSTER_NAME}' does not exist; 'cluster'/'all' will create it" + warn "using kind-config.yaml (NodePort 30080 → host 30080 mapping)." + warn "if that's not what you want, create the cluster externally first," + warn "then run './deploy_kind.sh -c ${CLUSTER_NAME} apply' to skip creation." + fi + say "creating kind cluster '${CLUSTER_NAME}'" + # kind-config.yaml hardcodes name: sap-demo; override via --name + kind create cluster --config kind-config.yaml --name "${CLUSTER_NAME}" + fi + kubectl config use-context "kind-${CLUSTER_NAME}" >/dev/null + say "kubectl context: $(kubectl config current-context)" +} + +build_and_load() { + if cluster_exists; then :; else + fail "cluster '${CLUSTER_NAME}' does not exist — run '$0 -c ${CLUSTER_NAME} cluster' first" + exit 1 + fi + if [ ! -f target/customer360.jar ] || [ src -nt target/customer360.jar ]; then + say "building customer360.jar (mvn package)" + mvn -q -B -DskipTests package + else + say "using existing target/customer360.jar" + fi + say "building docker image ${IMAGE_TAG}" + DOCKER_BUILDKIT=0 docker build --pull=false -t "${IMAGE_TAG}" . + say "loading image into kind cluster '${CLUSTER_NAME}'" + kind load docker-image "${IMAGE_TAG}" --name "${CLUSTER_NAME}" +} + +# Check if ${IMAGE_TAG} is already loaded into the target cluster's node. +# `kind load` is per-cluster — an image loaded into cluster A is invisible +# to cluster B. This guards against ImagePullBackOff on cross-cluster applies. +image_in_cluster() { + local node_container="${CLUSTER_NAME}-control-plane" + if ! docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "${node_container}"; then + return 1 + fi + docker exec "${node_container}" crictl images 2>/dev/null \ + | awk '{print $1":"$2}' | grep -qx "docker.io/library/${IMAGE_TAG}" +} + +# Ensure the image is present in the cluster; load it if not. +# Called from apply_manifests so that `./deploy_kind.sh apply` against a +# fresh cluster or a different cluster still works without a prior `build`. +ensure_image_in_cluster() { + if image_in_cluster; then + say "image ${IMAGE_TAG} already present in cluster '${CLUSTER_NAME}'" + return 0 + fi + warn "image ${IMAGE_TAG} not present in cluster '${CLUSTER_NAME}' — loading it now" + # Make sure the image exists on the host first. + if ! docker image inspect "${IMAGE_TAG}" >/dev/null 2>&1; then + say "host image ${IMAGE_TAG} missing too — running full build" + build_and_load + return $? + fi + say "loading host-cached ${IMAGE_TAG} into cluster '${CLUSTER_NAME}'" + kind load docker-image "${IMAGE_TAG}" --name "${CLUSTER_NAME}" +} + +apply_manifests() { + if cluster_exists; then :; else + fail "cluster '${CLUSTER_NAME}' does not exist — nothing to apply to" + exit 1 + fi + kubectl config use-context "kind-${CLUSTER_NAME}" >/dev/null + ensure_secret + ensure_image_in_cluster + ensure_ingress_controller + say "applying k8s manifests into context kind-${CLUSTER_NAME}" + kubectl apply -f k8s/namespace.yaml + kubectl apply -f k8s/postgres.yaml + say "waiting for Postgres rollout" + kubectl -n "${NS}" rollout status deployment/postgres --timeout=120s + kubectl apply -f k8s/configmap.yaml + kubectl apply -f k8s/secret.yaml + kubectl apply -f k8s/deployment.yaml + kubectl apply -f k8s/service.yaml + # Retry the Ingress apply a couple of times — the admission webhook can + # briefly 503 right after the controller comes up. + for attempt in 1 2 3; do + if kubectl apply -f k8s/ingress.yaml; then + break + fi + warn "ingress apply failed (attempt ${attempt}/3), retrying in 5s…" + sleep 5 + done + + say "waiting for rollout" + if ! kubectl -n "${NS}" rollout status deployment/customer360 --timeout=180s; then + fail "rollout did not complete — diagnosing" + kubectl -n "${NS}" get pods -o wide + # Surface ImagePullBackOff specifically, since it's the most common + # cross-cluster apply failure mode. + local ipbp + ipbp=$(kubectl -n "${NS}" get pod -l app.kubernetes.io/name=customer360 \ + -o jsonpath='{.items[*].status.containerStatuses[*].state.waiting.reason}' 2>/dev/null) + if echo "${ipbp}" | grep -qE "ImagePullBackOff|ErrImagePull|ErrImageNeverPull"; then + warn "pod can't find the image — '${IMAGE_TAG}' is not on the cluster's node." + warn "fix: ./deploy_kind.sh -c ${CLUSTER_NAME} build" + warn " (that rebuilds + kind-loads into THIS cluster specifically)" + fi + exit 1 + fi + + check_port_mappings + + say "ready. preferred URL (Ingress, port 80):" + if [ "${HAS_HTTP:-0}" = 1 ]; then + echo " curl -s http://customer360.localtest.me/actuator/health | jq ." + echo " curl -s http://customer360.localtest.me/api/v1/customers/count | jq ." + echo " curl -s http://customer360.localtest.me/api/v1/customers/202/360 | jq ." + echo " open http://customer360.localtest.me/swagger-ui.html" + fi + if [ "${HAS_NODEPORT:-0}" = 1 ]; then + say "also available (NodePort, port 30080):" + echo " curl -s http://localhost:30080/actuator/health | jq ." + fi + if [ "${HAS_HTTP:-0}" = 0 ] && [ "${HAS_NODEPORT:-0}" = 0 ]; then + say "fallback via port-forward:" + echo " kubectl -n ${NS} port-forward svc/customer360 8080:8080" + echo " curl -s http://localhost:8080/actuator/health | jq ." + fi +} + +show_status() { + kubectl config use-context "kind-${CLUSTER_NAME}" >/dev/null + say "nodes" + kubectl get nodes -o wide + say "pods" + kubectl -n "${NS}" get pods -o wide + say "service" + kubectl -n "${NS}" get svc + say "events (last 10)" + kubectl -n "${NS}" get events --sort-by='.lastTimestamp' | tail -10 +} + +tail_logs() { + kubectl config use-context "kind-${CLUSTER_NAME}" >/dev/null + POD=$(kubectl -n "${NS}" get pod -l app.kubernetes.io/name=customer360 -o name | head -n1) + if [ -z "${POD}" ]; then + fail "no customer360 pod found in namespace ${NS} on kind-${CLUSTER_NAME}" + exit 1 + fi + kubectl -n "${NS}" logs -f "${POD}" +} + +destroy() { + say "deleting kind cluster '${CLUSTER_NAME}'" + kind delete cluster --name "${CLUSTER_NAME}" || true +} + +cmd="${1:-all}" +case "$cmd" in + all|"") + check_prereqs + create_cluster + build_and_load + apply_manifests + ;; + cluster) check_prereqs; create_cluster ;; + build) check_prereqs; build_and_load ;; + apply) check_prereqs; apply_manifests ;; + status) check_prereqs; show_status ;; + logs) check_prereqs; tail_logs ;; + destroy) check_prereqs; destroy ;; + *) + echo "usage: $0 [--cluster NAME | -c NAME] [cluster|build|apply|status|logs|destroy|all]" + exit 2 + ;; +esac diff --git a/sap-demo-java/docker-compose.yml b/sap-demo-java/docker-compose.yml new file mode 100644 index 00000000..807ec95e --- /dev/null +++ b/sap-demo-java/docker-compose.yml @@ -0,0 +1,44 @@ +# Local-only runner — convenient for quick iteration without kind. +# Starts Postgres + the Spring Boot app; the app waits for Postgres via +# depends_on/healthcheck. +# +# For the demo, prefer ./deploy_kind.sh (matches production topology). +services: + postgres: + image: postgres:15-alpine + container_name: customer360-postgres + environment: + POSTGRES_DB: customer360 + POSTGRES_USER: customer360 + POSTGRES_PASSWORD: customer360 + PGDATA: /var/lib/postgresql/data/pgdata + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD", "pg_isready", "-U", "customer360", "-d", "customer360"] + interval: 5s + timeout: 3s + retries: 10 + + customer360: + build: . + container_name: customer360 + image: customer360:local + depends_on: + postgres: + condition: service_healthy + env_file: + - .env + environment: + SPRING_PROFILES_ACTIVE: default + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/customer360 + SPRING_DATASOURCE_USERNAME: customer360 + SPRING_DATASOURCE_PASSWORD: customer360 + ports: + - "8080:8080" + restart: unless-stopped + +volumes: + pgdata: diff --git a/sap-demo-java/k8s/configmap.yaml b/sap-demo-java/k8s/configmap.yaml new file mode 100644 index 00000000..d85f516f --- /dev/null +++ b/sap-demo-java/k8s/configmap.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: customer360-config + namespace: sap-demo + labels: + app.kubernetes.io/name: customer360 + app.kubernetes.io/component: integration-service +data: + # Non-secret runtime config. Override per-environment. + SPRING_PROFILES_ACTIVE: "kubernetes" + SAP_API_BASE_URL: "https://sandbox.api.sap.com/s4hanacloud" + SERVER_PORT: "8080" + JAVA_OPTS: "-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError" diff --git a/sap-demo-java/k8s/deployment.yaml b/sap-demo-java/k8s/deployment.yaml new file mode 100644 index 00000000..d265237c --- /dev/null +++ b/sap-demo-java/k8s/deployment.yaml @@ -0,0 +1,113 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: customer360 + namespace: sap-demo + labels: + app.kubernetes.io/name: customer360 + app.kubernetes.io/component: integration-service + app.kubernetes.io/part-of: customer360 + app.kubernetes.io/version: "1.0.0" +spec: + replicas: 1 + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + selector: + matchLabels: + app.kubernetes.io/name: customer360 + template: + metadata: + labels: + app.kubernetes.io/name: customer360 + app.kubernetes.io/component: integration-service + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "8080" + prometheus.io/path: "/actuator/prometheus" + # Marker read by Keploy k8s-proxy when live-recording is enabled. + keploy.io/record: "enabled" + spec: + terminationGracePeriodSeconds: 30 + initContainers: + # Wait for Postgres to accept connections before the Spring app starts. + # Avoids a noisy Flyway/DataSource retry loop in the main container. + - name: wait-for-postgres + image: postgres:15-alpine + imagePullPolicy: IfNotPresent + command: + - sh + - -c + - | + echo "waiting for postgres.sap-demo.svc.cluster.local:5432" + until pg_isready -h postgres -p 5432 -U customer360 -d customer360 >/dev/null 2>&1; do + sleep 2 + done + echo "postgres ready" + containers: + - name: customer360 + image: customer360:local + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 8080 + protocol: TCP + envFrom: + - configMapRef: + name: customer360-config + - secretRef: + name: customer360-secrets + env: + - name: SPRING_DATASOURCE_URL + value: jdbc:postgresql://postgres.sap-demo.svc.cluster.local:5432/customer360 + - name: SPRING_DATASOURCE_USERNAME + valueFrom: + secretKeyRef: + name: customer360-db + key: POSTGRES_USER + - name: SPRING_DATASOURCE_PASSWORD + valueFrom: + secretKeyRef: + name: customer360-db + key: POSTGRES_PASSWORD + resources: + requests: + cpu: "200m" + memory: "384Mi" + limits: + cpu: "1000m" + memory: "768Mi" + startupProbe: + httpGet: + path: /actuator/health/liveness + port: http + periodSeconds: 3 + failureThreshold: 40 + livenessProbe: + httpGet: + path: /actuator/health/liveness + port: http + periodSeconds: 10 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /actuator/health/readiness + port: http + periodSeconds: 5 + failureThreshold: 3 + securityContext: + runAsNonRoot: true + runAsUser: 1001 + runAsGroup: 1001 + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + volumeMounts: + - name: tmp + mountPath: /tmp + volumes: + - name: tmp + emptyDir: {} diff --git a/sap-demo-java/k8s/ingress.yaml b/sap-demo-java/k8s/ingress.yaml new file mode 100644 index 00000000..1e21b2fd --- /dev/null +++ b/sap-demo-java/k8s/ingress.yaml @@ -0,0 +1,42 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: customer360 + namespace: sap-demo + labels: + app.kubernetes.io/name: customer360 + annotations: + # Send the upstream request with the original Host header so Spring + # Boot's forward-headers-strategy sees the correct X-Forwarded-Host. + nginx.ingress.kubernetes.io/upstream-vhost: "$host" +spec: + # Use the ingress-nginx class by default. ingress-nginx is installed + # automatically by deploy_kind.sh when the target cluster has no + # IngressClass yet. + ingressClassName: nginx + rules: + # localtest.me is a public DNS record that resolves *.localtest.me to + # 127.0.0.1. No /etc/hosts editing required — just + # curl http://customer360.localtest.me/actuator/health + - host: customer360.localtest.me + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: customer360 + port: + number: 8080 + # Fallback: plain localhost also works, for scripts / smoke tests that + # don't care about Host headers. + - host: localhost + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: customer360 + port: + number: 8080 diff --git a/sap-demo-java/k8s/namespace.yaml b/sap-demo-java/k8s/namespace.yaml new file mode 100644 index 00000000..1a4f2692 --- /dev/null +++ b/sap-demo-java/k8s/namespace.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: sap-demo + labels: + app.kubernetes.io/part-of: customer360 + # Marker for Keploy's k8s-proxy: treat this namespace as a candidate for + # sidecar injection / live recording. Ignored if k8s-proxy isn't installed. + keploy.io/enabled: "true" diff --git a/sap-demo-java/k8s/postgres.yaml b/sap-demo-java/k8s/postgres.yaml new file mode 100644 index 00000000..4a7276e6 --- /dev/null +++ b/sap-demo-java/k8s/postgres.yaml @@ -0,0 +1,108 @@ +# Postgres 15 for the Customer 360 local store (tags, notes, audit). +# +# Kept in a single file for demo-legibility: Secret + Service + Deployment. +# emptyDir volume (not PVC) — fresh DB on every pod restart, which is fine +# for demo/recording purposes and eliminates PVC provisioner complexity. +# +# For a production deployment this should be a managed Postgres (AWS RDS, +# Azure DB for Postgres, SAP HANA Cloud, etc.) or at minimum a StatefulSet +# with a real PVC. +--- +apiVersion: v1 +kind: Secret +metadata: + name: customer360-db + namespace: sap-demo + labels: + app.kubernetes.io/name: customer360 + app.kubernetes.io/component: database +type: Opaque +stringData: + POSTGRES_DB: customer360 + POSTGRES_USER: customer360 + POSTGRES_PASSWORD: customer360 +--- +apiVersion: v1 +kind: Service +metadata: + name: postgres + namespace: sap-demo + labels: + app.kubernetes.io/name: postgres + app.kubernetes.io/component: database +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: postgres + ports: + - name: postgres + port: 5432 + targetPort: postgres + protocol: TCP +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: postgres + namespace: sap-demo + labels: + app.kubernetes.io/name: postgres + app.kubernetes.io/component: database +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app.kubernetes.io/name: postgres + template: + metadata: + labels: + app.kubernetes.io/name: postgres + app.kubernetes.io/component: database + annotations: + # Tell Keploy's k8s-proxy that this pod produces recordable + # Postgres wire-protocol traffic from the customer360 pod. + keploy.io/capture: "postgres" + spec: + terminationGracePeriodSeconds: 15 + containers: + - name: postgres + image: postgres:15-alpine + imagePullPolicy: IfNotPresent + ports: + - name: postgres + containerPort: 5432 + protocol: TCP + envFrom: + - secretRef: + name: customer360-db + env: + - name: PGDATA + value: /var/lib/postgresql/data/pgdata + resources: + requests: + cpu: "100m" + memory: "128Mi" + limits: + cpu: "500m" + memory: "256Mi" + readinessProbe: + exec: + command: ["pg_isready", "-U", "customer360", "-d", "customer360"] + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 5 + livenessProbe: + exec: + command: ["pg_isready", "-U", "customer360", "-d", "customer360"] + initialDelaySeconds: 15 + periodSeconds: 15 + timeoutSeconds: 3 + failureThreshold: 4 + volumeMounts: + - name: data + mountPath: /var/lib/postgresql/data + volumes: + - name: data + emptyDir: {} diff --git a/sap-demo-java/k8s/secret.yaml.example b/sap-demo-java/k8s/secret.yaml.example new file mode 100644 index 00000000..7351bd4c --- /dev/null +++ b/sap-demo-java/k8s/secret.yaml.example @@ -0,0 +1,23 @@ +# Copy to secret.yaml and fill in SAP_API_KEY before applying. +# secret.yaml is gitignored; this example file is not. +# +# cp k8s/secret.yaml.example k8s/secret.yaml +# # edit SAP_API_KEY below, then: +# kubectl apply -f k8s/secret.yaml +# +apiVersion: v1 +kind: Secret +metadata: + name: customer360-secrets + namespace: sap-demo + labels: + app.kubernetes.io/name: customer360 +type: Opaque +stringData: + # SAP Business Accelerator Hub sandbox API key. + # Get a free one at https://api.sap.com → any API → "Show API Key". + SAP_API_KEY: "" + + # Optional — only needed when pointing at a real BTP tenant that uses + # OAuth2 xsuaa bearer tokens. For the sandbox this stays empty. + SAP_BEARER_TOKEN: "" diff --git a/sap-demo-java/k8s/service.yaml b/sap-demo-java/k8s/service.yaml new file mode 100644 index 00000000..16fdf69c --- /dev/null +++ b/sap-demo-java/k8s/service.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: Service +metadata: + name: customer360 + namespace: sap-demo + labels: + app.kubernetes.io/name: customer360 +spec: + type: NodePort + selector: + app.kubernetes.io/name: customer360 + ports: + - name: http + protocol: TCP + port: 8080 + targetPort: http + # The kind cluster (kind-config.yaml) exposes 30080 to the host. + # http://localhost:30080 → this Service → the pod. + nodePort: 30080 diff --git a/sap-demo-java/keploy.yml b/sap-demo-java/keploy.yml new file mode 100755 index 00000000..4e96c152 --- /dev/null +++ b/sap-demo-java/keploy.yml @@ -0,0 +1,122 @@ +# Generated by Keploy (3-dev) +path: "" +appName: sap_demo_java +appId: 0 +command: java -jar target/customer360.jar +templatize: + testSets: [] +port: 0 +e2e: false +dnsPort: 26789 +proxyPort: 16789 +incomingProxyPort: 36789 +debug: false +disableTele: false +disableANSI: false +jsonOutput: false +containerName: "" +networkName: "" +buildDelay: 30 +test: + selectedTests: {} + globalNoise: + global: + header.X-Correlation-Id: [] + body.correlationId: [] + body.timestamp: [] + body.installedOn: [] + body.id: [] + # /actuator/health Spring Boot DataSourceHealthIndicator can + # flip between UP and DOWN depending on Hikari pool warmup + # state at the instant of the probe; the actual DB + # connectivity is already proven by every downstream test + # that hits the persistence layer successfully. + body.status: [] + test-sets: {} + replaceWith: + global: + url: {} + port: {} + test-sets: {} + delay: 5 + host: localhost + port: 0 + grpcPort: 0 + ssePort: 0 + protocol: + grpc: + port: 0 + http: + port: 0 + sse: + port: 0 + apiTimeout: 5 + skipCoverage: false + coverageReportPath: "" + ignoreOrdering: true + mongoPassword: default@123 + language: "" + removeUnusedMocks: false + preserveFailedMocks: false + fallBackOnMiss: false + jacocoAgentPath: "" + basePath: "" + mocking: true + ignoredTests: {} + disableLineCoverage: false + disableMockUpload: true + useLocalMock: false + updateTemplate: false + mustPass: false + maxFailAttempts: 5 + maxFlakyChecks: 1 + protoFile: "" + protoDir: "" + protoInclude: [] + compareAll: false + schemaMatch: false + updateTestMapping: false + disableAutoHeaderNoise: false + strictMockWindow: true +record: + filters: [] + basePath: "" + recordTimer: 0s + metadata: "" + sync: false + enableSampling: 0 + memoryLimit: 0 + globalPassthrough: false + tlsPrivateKeyPath: "" + mockFormat: yaml +report: + selectedTestSets: {} + showFullBody: false + reportPath: "" + summary: false + testCaseIDs: [] + format: "" +disableMapping: true +retryPassing: false +configPath: "" +bypassRules: [] +generateGithubActions: false +keployContainer: keploy-v3 +keployNetwork: keploy-network +cmdType: native +contract: + services: [] + tests: [] + path: "" + download: false + generate: false + driven: consumer + mappings: + servicesMapping: {} + self: s1 +inCi: false +serverPort: 0 +mockDownload: + registryIds: [] + +# Visit [https://keploy.io/docs/running-keploy/configuration-file/] to learn about using keploy through configration file. diff --git a/sap-demo-java/kind-config.yaml b/sap-demo-java/kind-config.yaml new file mode 100644 index 00000000..efb4fcab --- /dev/null +++ b/sap-demo-java/kind-config.yaml @@ -0,0 +1,17 @@ +kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 +nodes: +- role: control-plane + kubeadmConfigPatches: + - | + kind: InitConfiguration + nodeRegistration: + kubeletExtraArgs: + node-labels: "ingress-ready=true" + extraPortMappings: + - containerPort: 80 + hostPort: 80 + protocol: TCP + - containerPort: 443 + hostPort: 443 + protocol: TCP \ No newline at end of file diff --git a/sap-demo-java/pom.xml b/sap-demo-java/pom.xml new file mode 100644 index 00000000..5c4d3a70 --- /dev/null +++ b/sap-demo-java/pom.xml @@ -0,0 +1,144 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.4 + + + + com.tricentisdemo.sap + customer360 + 1.0.0 + jar + SAP Customer 360 Service + + Enterprise-grade Spring Boot integration service that aggregates SAP Business Partner + data from multiple S/4HANA OData endpoints into a unified Customer 360 view. + Designed as a reference implementation for RISE with SAP landscapes — the kind of + BTP-style integration middleware Tricentis customers build and must regression-test + during ECC → S/4HANA Cloud migrations. + + + + 21 + 21 + 21 + UTF-8 + 2.2.0 + 2.6.0 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.apache.httpcomponents.client5 + httpclient5 + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.springframework.boot + spring-boot-starter-cache + + + com.github.ben-manes.caffeine + caffeine + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.postgresql + postgresql + runtime + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-database-postgresql + + + + + org.springframework.boot + spring-boot-starter-actuator + + + io.micrometer + micrometer-registry-prometheus + + + + + io.github.resilience4j + resilience4j-spring-boot3 + ${resilience4j.version} + + + io.github.resilience4j + resilience4j-micrometer + ${resilience4j.version} + + + org.springframework.boot + spring-boot-starter-aop + + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc.version} + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + customer360 + + + org.springframework.boot + spring-boot-maven-plugin + + + true + + + + + + diff --git a/sap-demo-java/run_flow.sh b/sap-demo-java/run_flow.sh new file mode 100755 index 00000000..1c895401 --- /dev/null +++ b/sap-demo-java/run_flow.sh @@ -0,0 +1,276 @@ +#!/usr/bin/env bash +# run_flow.sh — drive the Customer 360 business flow against a deployed API. +# +# Each "iteration" plays a 20-call slice of realistic enterprise behaviour: +# KPI refresh → detail drill-downs → 360° composites → write operations +# (tags + notes) → cleanup → one negative validation test. 25 iterations +# = 500 inbound calls = roughly 1000+ outbound SAP/Postgres operations +# for Keploy to record at the wire level. +# +# Varies the customer ID across a small pool (11, 202, 203) to get +# diverse SAP responses, and generates unique tag names per call so +# writes always take the insert path (not the idempotent no-op path). +# +# Auto-detects the base URL; override with --host. +# +# Usage: +# ./run_flow.sh # default: 25 iterations → 500 calls +# ./run_flow.sh --iterations 50 # 50 iterations → 1000 calls +# ./run_flow.sh --host http://... # explicit URL +# ./run_flow.sh --trace # + tail correlated pod logs at end +# ./run_flow.sh --quiet # summary only +# ./run_flow.sh --iterations 1 --verbose # narrate every call in detail +# +# Exit code: 0 if all calls passed. Non-zero otherwise. + +set -euo pipefail + +cd "$(dirname "$0")" + +# --- colours ---------------------------------------------------------------- +BOLD='\033[1m'; DIM='\033[2m'; NC='\033[0m' +GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m' + +say() { [ "${QUIET:-0}" = 1 ] || printf "${BOLD}${GREEN}==>${NC} %s\n" "$*"; } +note() { [ "${QUIET:-0}" = 1 ] || printf "${DIM}%s${NC}\n" "$*"; } +warn() { printf "${BOLD}${YELLOW}!!${NC} %s\n" "$*"; } +fail() { printf "${BOLD}${RED}XX${NC} %s\n" "$*"; } + +# --- args ------------------------------------------------------------------- +HOST="" +ITERATIONS=25 +TRACE=0 +QUIET=0 +VERBOSE=0 +while [ $# -gt 0 ]; do + case "$1" in + --host) HOST="$2"; shift 2 ;; + --iterations) ITERATIONS="$2"; shift 2 ;; + --trace) TRACE=1; shift ;; + --quiet) QUIET=1; shift ;; + --verbose) VERBOSE=1; shift ;; + -h|--help) + sed -n '2,26p' "$0" | sed 's/^# \{0,1\}//' + exit 0 ;; + *) fail "unknown arg: $1"; exit 2 ;; + esac +done + +# --- URL auto-detect -------------------------------------------------------- +CANDIDATES=( + "${HOST:-}" + "http://customer360.localtest.me" + "http://localhost" + "http://localhost:30080" + "http://localhost:8080" +) +BASE="" +for u in "${CANDIDATES[@]}"; do + [ -z "$u" ] && continue + if curl -s -o /dev/null -m 3 -f "$u/actuator/health/liveness" 2>/dev/null; then + BASE="$u" + break + fi +done +if [ -z "$BASE" ]; then + fail "Customer 360 not reachable on any of:" + for u in "${CANDIDATES[@]}"; do [ -n "$u" ] && printf " %s\n" "$u"; done + fail "Is the deployment up? Try: sudo kubectl -n sap-demo get pods" + exit 1 +fi +say "target: ${BOLD}${BASE}${NC} iterations: ${ITERATIONS} expected calls: $((ITERATIONS * 20))" + +# --- per-run correlation prefix -------------------------------------------- +RUN_ID="flow-$(date +%Y%m%d-%H%M%S)-$$" + +# --- tracking state -------------------------------------------------------- +PASS_COUNT=0 +FAIL_COUNT=0 +FAIL_LAST="" +CALL_SEQ=0 +CNT_SAP=0 +CNT_DB=0 +CNT_MIXED=0 +CNT_WRITE=0 + +# Quiet call helper: no per-line output unless VERBOSE=1. Still counts. +# Args: category (SAP|DB|MIXED|WRITE), method, path, [extra curl args...] +# Optional trailing `-- EXPECT N` at the end to assert a specific HTTP code. +call() { + local category="$1" method="$2" path="$3"; shift 3 + local expect="" + # Extract optional "-- EXPECT N" from args + local filtered=() + while [ $# -gt 0 ]; do + if [ "$1" = "--" ] && [ "${2:-}" = "EXPECT" ] && [ -n "${3:-}" ]; then + expect="$3"; shift 3 + else + filtered+=("$1"); shift + fi + done + + local cid="${RUN_ID}-${CALL_SEQ}" + CALL_SEQ=$((CALL_SEQ + 1)) + + local status + if [ ${#filtered[@]} -gt 0 ]; then + status=$(curl -sS -m 90 -o /dev/null -w "%{http_code}" \ + -X "${method}" \ + -H "X-Correlation-ID: ${cid}" \ + "${filtered[@]}" \ + "${BASE}${path}" || echo "000") + else + status=$(curl -sS -m 90 -o /dev/null -w "%{http_code}" \ + -X "${method}" \ + -H "X-Correlation-ID: ${cid}" \ + "${BASE}${path}" || echo "000") + fi + + local pass=0 + if [ -n "${expect}" ]; then + [ "${status}" = "${expect}" ] && pass=1 + else + [[ "${status}" =~ ^2 ]] && pass=1 + fi + + if [ "${pass}" = 1 ]; then + PASS_COUNT=$((PASS_COUNT + 1)) + case "${category}" in + SAP) CNT_SAP=$((CNT_SAP + 1)) ;; + DB) CNT_DB=$((CNT_DB + 1)) ;; + MIXED) CNT_MIXED=$((CNT_MIXED + 1)) ;; + WRITE) CNT_WRITE=$((CNT_WRITE + 1)) ;; + esac + if [ "${VERBOSE}" = 1 ]; then + printf " ${GREEN}${status}${NC} %-5s %-42s ${DIM}[%s]${NC}\n" "${method}" "${path}" "${category}" + fi + else + FAIL_COUNT=$((FAIL_COUNT + 1)) + FAIL_LAST="${method} ${path} → got ${status}${expect:+ (expected ${expect})}" + printf " ${RED}${status}${NC} %-5s %-42s ${DIM}[%s]${NC} ${RED}FAIL${NC}\n" \ + "${method}" "${path}" "${category}" + fi +} + +# --- the per-iteration flow (20 calls) ------------------------------------- +# Breakdown: +# A tile-refresh block → 5 calls (2 DB-light health, 2 SAP, 1 DB-read) +# B detail drill-downs → 6 calls (2 SAP + 4 DB) +# C 360° composites → 3 calls (MIXED: each fires 3 SAP + 2 DB + 1 audit) +# D write operations → 4 calls (2 tag inserts + 2 note inserts, all WRITE) +# E cleanup + negative test → 2 calls (1 DELETE, 1 validation 400) +BP_POOL=(11 202 203) +run_once() { + local iter="$1" + local bp_a="${BP_POOL[$((iter % 3))]}" + local bp_b="${BP_POOL[$(((iter + 1) % 3))]}" + local bp_c="${BP_POOL[$(((iter + 2) % 3))]}" + + # Block A — platform/KPI refresh + call DB GET /actuator/health/liveness + call DB GET /actuator/health/readiness + call SAP GET /api/v1/customers/count + call SAP GET "/api/v1/customers?top=5" + call DB GET /api/v1/customers/recent-views + + # Block B — detail drill-downs + call SAP GET "/api/v1/customers/${bp_a}" + call SAP GET "/api/v1/customers/${bp_b}" + call DB GET "/api/v1/customers/${bp_a}/tags" + call DB GET "/api/v1/customers/${bp_b}/tags" + call DB GET "/api/v1/customers/${bp_a}/notes" + call DB GET "/api/v1/customers/${bp_b}/notes" + + # Block C — 360° (each: 3 SAP + 2 DB + 1 audit insert — the fan-out story) + call MIXED GET "/api/v1/customers/${bp_a}/360" + call MIXED GET "/api/v1/customers/${bp_b}/360" + call MIXED GET "/api/v1/customers/${bp_c}/360" + + # Block D — writes (unique tag/note per call so every INSERT takes) + local nonce="i${iter}-$(printf '%04x' $((RANDOM + CALL_SEQ)))" + call WRITE POST "/api/v1/customers/${bp_a}/tags" \ + -H "Content-Type: application/json" \ + -d "{\"tag\":\"demo-${nonce}\",\"createdBy\":\"flow\"}" + call WRITE POST "/api/v1/customers/${bp_b}/tags" \ + -H "Content-Type: application/json" \ + -d "{\"tag\":\"priority-${nonce}\",\"createdBy\":\"flow\"}" + call WRITE POST "/api/v1/customers/${bp_a}/notes" \ + -H "Content-Type: application/json" \ + -d "{\"body\":\"Iteration ${iter}: customer profile reviewed by flow harness\",\"author\":\"flow\"}" + call WRITE POST "/api/v1/customers/${bp_b}/notes" \ + -H "Content-Type: application/json" \ + -d "{\"body\":\"Iteration ${iter}: follow-up scheduled\",\"author\":\"flow\"}" + + # Block E — cleanup + negative validation + call WRITE DELETE "/api/v1/customers/${bp_a}/tags/demo-${nonce}" + call DB GET "/api/v1/customers/bad!!id/360" -- EXPECT 400 +} + +# --- main loop -------------------------------------------------------------- +START_TS=$(date +%s) +LAST_PROGRESS=0 +for i in $(seq 1 "${ITERATIONS}"); do + run_once "$i" + # Progress line every 5% or every 5 iterations, whichever is coarser. + local_mod=$(( ITERATIONS / 20 )) + [ "${local_mod}" -lt 1 ] && local_mod=1 + if [ $((i % local_mod)) = 0 ] || [ "$i" = "${ITERATIONS}" ]; then + if [ "${QUIET}" != 1 ]; then + printf "${DIM} [iter %3d/%d] calls=%-4d pass=%d fail=%d${NC}\n" \ + "$i" "${ITERATIONS}" "${CALL_SEQ}" "${PASS_COUNT}" "${FAIL_COUNT}" + fi + fi +done +END_TS=$(date +%s) + +# --- optional log tail ------------------------------------------------------ +if [ "${TRACE}" = 1 ]; then + say "trace: a sample of pod log lines tagged with run id ${RUN_ID} (first 60)" + if command -v kubectl >/dev/null; then + sudo kubectl -n sap-demo logs deploy/customer360 --tail=$((ITERATIONS * 100)) 2>/dev/null \ + | grep -F "${RUN_ID}" \ + | head -60 \ + | python3 -c " +import json,sys +for line in sys.stdin: + try: + d=json.loads(line) + logger=d.get('logger','').split('.')[-1] + print(f\" {d['ts']} {logger:<32} {d['msg'][:140]}\") + except Exception: pass" || true + else + warn "kubectl not on PATH — skipping log tail" + fi +fi + +# --- summary ---------------------------------------------------------------- +TOTAL=$((PASS_COUNT + FAIL_COUNT)) +WALL=$((END_TS - START_TS)) +[ "${WALL}" -le 0 ] && WALL=1 +RPS=$(awk -v t="${TOTAL}" -v w="${WALL}" 'BEGIN { printf "%.1f", t/w }') + +printf "\n${BOLD}───────────────────────────────────────────────────────────────${NC}\n" +printf "${BOLD} %s${NC}\n" "$(basename "${BASE}") — ${ITERATIONS} iterations in ${WALL}s (${RPS} req/s)" +printf "${BOLD}───────────────────────────────────────────────────────────────${NC}\n" +printf " SAP-backed reads %d\n" "${CNT_SAP}" +printf " Postgres-only reads %d\n" "${CNT_DB}" +printf " MIXED (SAP + DB fan-outs) %d ${DIM}(each ≈ 6 backend ops)${NC}\n" "${CNT_MIXED}" +printf " Writes (inserts/deletes) %d\n" "${CNT_WRITE}" +printf " ──────────────────────────────────────\n" +if [ "${FAIL_COUNT}" = 0 ]; then + printf " ${BOLD}${GREEN}PASS${NC} %d/%d calls ok\n" "${PASS_COUNT}" "${TOTAL}" + printf " ${DIM}run id: ${RUN_ID}${NC}\n" + printf "\n ${DIM}estimated backend operations captured by Keploy:${NC}\n" + # SAP: 1 HTTP each. DB: 1 Postgres query each. MIXED: ~6 (3 SAP + 2 DB + 1 INSERT). + # WRITE: 2 (1 INSERT + 1 audit INSERT). + BACKEND=$(( CNT_SAP + CNT_DB + (CNT_MIXED * 6) + (CNT_WRITE * 2) )) + printf " ${DIM} SAP HTTPS: ~%d Postgres: ~%d Total: ~%d${NC}\n" \ + "$(( CNT_SAP + (CNT_MIXED * 3) ))" \ + "$(( CNT_DB + (CNT_MIXED * 3) + (CNT_WRITE * 2) ))" \ + "${BACKEND}" + exit 0 +else + printf " ${BOLD}${RED}FAIL${NC} %d/%d calls failed\n" "${FAIL_COUNT}" "${TOTAL}" + printf " last error: %s\n" "${FAIL_LAST}" + exit 1 +fi diff --git a/sap-demo-java/simulate_tosca_flow.sh b/sap-demo-java/simulate_tosca_flow.sh new file mode 100755 index 00000000..822ece43 --- /dev/null +++ b/sap-demo-java/simulate_tosca_flow.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# simulate_tosca_flow.sh — narrated "Tosca drives Fiori; Keploy records the +# 3-way fan-out underneath" demo for the Customer 360 service. +# +# Run this while `keploy record` is recording the service in another terminal. +# Default target is the in-cluster service exposed at http://localhost:30080 +# (NodePort from kind). Override with --host for local runs. + +set -euo pipefail + +cd "$(dirname "$0")" + +HOST="${HOST:-http://localhost:30080}" +FAST=0 + +while [ $# -gt 0 ]; do + case "$1" in + --fast) FAST=1; shift ;; + --host) HOST="$2"; shift 2 ;; + -h|--help) + sed -n '2,9p' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) echo "unknown arg: $1"; exit 2 ;; + esac +done + +BOLD='\033[1m'; DIM='\033[2m'; GREEN='\033[0;32m'; BLUE='\033[0;34m' +YELLOW='\033[1;33m'; MAGENTA='\033[0;35m'; NC='\033[0m' + +pause() { [ "$FAST" = "1" ] || sleep "${1:-2}"; } +tosca_step() { printf "\n${BOLD}${MAGENTA}[TOSCA UI]${NC} %s\n" "$1"; pause 1; } +tosca_click() { printf "${DIM} ↳ clicks:${NC} %s\n" "$1"; pause 1; } +backend() { printf "${BOLD}${BLUE}[KEPLOY ]${NC} ${DIM}%s${NC}\n" "$1"; } + +call() { + local label="$1"; shift + local status + status=$(curl -sw '%{http_code}' -o /tmp/simulate-body.json "$@" || echo "000") + local color="${GREEN}" + [[ "$status" =~ ^[45] ]] && color="${YELLOW}" + printf "${BOLD}${color}[HTTP ]${NC} %-48s %s\n" "$label" "$status" +} + +check_reachable() { + if ! curl -sf -o /dev/null "${HOST}/actuator/health" 2>/dev/null; then + printf "${BOLD}${YELLOW}service not reachable at ${HOST}${NC}\n" + printf "start it first:\n" + printf " ${DIM}./deploy_kind.sh${NC} (k8s mode)\n" + printf " ${DIM}./demo_script.sh record-local${NC} (local mode)\n" + exit 1 + fi +} + +banner() { + printf "\n${BOLD}" + printf "═══════════════════════════════════════════════════════════════════\n" + printf " Simulated Tosca-driven Fiori flow: 'Customer 360 in Sales Cockpit'\n" + printf " Keploy records every outbound SAP OData call in parallel.\n" + printf " Target: ${HOST}\n" + printf "═══════════════════════════════════════════════════════════════════${NC}\n" +} + +check_reachable +banner +pause 2 + +# ───────────────────────────────────────────────────────────────────────────── +tosca_step "Opening Sales Cockpit → clicking 'Customers' tile" +tosca_click "Customers launchpad tile" +backend "Tile click fans out: list query + KPI count" +call "GET /api/v1/customers/count (KPI tile)" "${HOST}/api/v1/customers/count" +pause 2 +call "GET /api/v1/customers?top=5 (list grid)" "${HOST}/api/v1/customers?top=5" +pause 2 + +# ───────────────────────────────────────────────────────────────────────────── +tosca_step "On the customer list, opening row BP=11" +tosca_click "Row BusinessPartner=11" +backend "Detail fetch — single SAP OData GET" +call "GET /api/v1/customers/11 (detail)" "${HOST}/api/v1/customers/11" +pause 2 + +# ───────────────────────────────────────────────────────────────────────────── +tosca_step "User clicks '360° view' on BP=202 — this is the fan-out moment" +tosca_click "360° view button" +backend "ONE inbound → THREE parallel SAP OData calls:" +backend " • /A_BusinessPartner('202')" +backend " • /A_BusinessPartner('202')/to_BusinessPartnerAddress" +backend " • /A_BusinessPartner('202')/to_BusinessPartnerRole" +backend "Tosca asserts on the UI tile. Keploy captures all three on the wire." +call "GET /api/v1/customers/202/360 (360 fan-out)" "${HOST}/api/v1/customers/202/360" +pause 3 + +# ───────────────────────────────────────────────────────────────────────────── +tosca_step "Drilling into a second customer — BP=11 360" +tosca_click "Back → select BP=11 → 360° view" +call "GET /api/v1/customers/11/360 (360 fan-out)" "${HOST}/api/v1/customers/11/360" +pause 2 + +# ───────────────────────────────────────────────────────────────────────────── +printf "\n${BOLD}${GREEN}" +printf "═══════════════════════════════════════════════════════════════════\n" +printf " Tosca flow complete. What happened in two panes:\n" +printf "═══════════════════════════════════════════════════════════════════${NC}\n\n" + +cat <<'EOF' + ┌─────────────────────────────┬─────────────────────────────────────────┐ + │ TOSCA (this terminal) │ KEPLOY (other terminal) │ + ├─────────────────────────────┼─────────────────────────────────────────┤ + │ • 5 Fiori interactions │ • 5 inbound HTTP test cases captured │ + │ • Asserted on UI state │ • ~11 outbound SAP OData mocks │ + │ • Zero backend visibility │ (2× 360 fan-out = 6 mocks alone) │ + │ │ • Full vertical slice: UI click→DB row │ + └─────────────────────────────┴─────────────────────────────────────────┘ + + One UI flow, two coverage layers. Tosca owns the surface. Keploy owns + the plumbing Tosca could not see before — especially the hidden parallel + fan-out behind the 360° tile. + + Stop Keploy in its terminal, then: + ./demo_script.sh offline-test (local mode) + + to replay the same flow with SAP blackholed in /etc/hosts. +EOF + +printf "\n" diff --git a/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/Customer360Application.java b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/Customer360Application.java new file mode 100644 index 00000000..5a4f81e8 --- /dev/null +++ b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/Customer360Application.java @@ -0,0 +1,47 @@ +package com.tricentisdemo.sap.customer360; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.info.Contact; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.info.License; +import io.swagger.v3.oas.annotations.servers.Server; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.scheduling.annotation.EnableAsync; + +/** + * Entry point for the SAP Customer 360 aggregator service. + * + * This service sits between downstream consumers (CRM, partner portals, + * analytics pipelines) and SAP S/4HANA's Business Partner OData APIs. A single + * inbound request for a "customer 360 view" triggers parallel fan-out calls to + * three SAP endpoints — the partner master record, associated addresses, and + * assigned roles — aggregated into a flat response. + * + * In a typical RISE with SAP landscape this would run as a BTP extension + * (Cloud Foundry or Kyma/Kubernetes). It's the kind of service that Tricentis + * LiveCompare flags as "impacted by migration" and that teams must regression- + * test end-to-end after every S/4HANA quarterly update. + */ +@SpringBootApplication +@EnableAsync +@EnableCaching +@OpenAPIDefinition( + info = @Info( + title = "SAP Customer 360 Service", + version = "1.0.0", + description = "Aggregates SAP Business Partner master data, addresses, and roles into a unified view.", + contact = @Contact(name = "Integration Platform Team", email = "integration@example.com"), + license = @License(name = "Internal — Reference Implementation") + ), + servers = { + @Server(url = "/", description = "In-cluster / local") + } +) +public class Customer360Application { + + public static void main(String[] args) { + SpringApplication.run(Customer360Application.class, args); + } +} diff --git a/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/config/SapClientConfig.java b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/config/SapClientConfig.java new file mode 100644 index 00000000..ada656ab --- /dev/null +++ b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/config/SapClientConfig.java @@ -0,0 +1,135 @@ +package com.tricentisdemo.sap.customer360.config; + +import com.tricentisdemo.sap.customer360.sap.CorrelationIdInterceptor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpRequest; +import org.springframework.http.client.ClientHttpRequestExecution; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.web.client.RestTemplate; + +import java.io.IOException; +import java.time.Duration; +import java.util.concurrent.Executor; + +/** + * Configures the {@link RestTemplate} used for all outbound calls to the SAP + * S/4HANA Business Partner OData service. + * + *

Three interceptors are attached: + *

    + *
  1. SapAuthInterceptor — stamps the required {@code APIKey} header + * (sandbox) or {@code Authorization: Bearer} (production tenant).
  2. + *
  3. IdentityEncodingInterceptor — forces {@code Accept-Encoding: identity} + * so bodies are not gzip-compressed in transit. In production this is + * optional; here it keeps Keploy-captured YAML mocks human-readable, + * which matters for demo-grade visibility and for diff-reviewing + * contract changes in pull requests.
  4. + *
  5. CorrelationIdInterceptor — propagates the inbound request's + * correlation id into the outbound SAP call so traces chain across + * the hop.
  6. + *
+ * + *

An {@link Executor} bean is also exposed for the + * {@link com.tricentisdemo.sap.customer360.service.Customer360AggregatorService} + * fan-out pattern. The three SAP OData calls run in parallel; the pool is + * intentionally small to avoid overwhelming the SAP API manager during + * regression test runs. + */ +@Configuration +@EnableAsync +public class SapClientConfig { + + private static final Logger log = LoggerFactory.getLogger(SapClientConfig.class); + + @Value("${sap.api.base-url}") + private String baseUrl; + + @Value("${sap.api.key:}") + private String apiKey; + + @Value("${sap.api.bearer-token:}") + private String bearerToken; + + @Value("${sap.api.connect-timeout-seconds:10}") + private int connectTimeoutSeconds; + + @Value("${sap.api.read-timeout-seconds:30}") + private int readTimeoutSeconds; + + @Bean + public RestTemplate sapRestTemplate(RestTemplateBuilder builder) { + log.info("Configuring SAP RestTemplate: baseUrl={}, connectTimeout={}s, readTimeout={}s, authMode={}", + baseUrl, connectTimeoutSeconds, readTimeoutSeconds, + !bearerToken.isBlank() ? "bearer" : !apiKey.isBlank() ? "apikey" : "NONE"); + + if (apiKey.isBlank() && bearerToken.isBlank()) { + log.warn("No SAP credentials configured (SAP_API_KEY / SAP_BEARER_TOKEN both empty). " + + "Outbound SAP calls will fail with 401."); + } + + HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(); + factory.setConnectTimeout((int) Duration.ofSeconds(connectTimeoutSeconds).toMillis()); + factory.setConnectionRequestTimeout((int) Duration.ofSeconds(connectTimeoutSeconds).toMillis()); + + return builder + .rootUri(baseUrl) + .requestFactory(() -> factory) + .additionalInterceptors( + new SapAuthInterceptor(apiKey, bearerToken), + new CorrelationIdInterceptor() + ) + .build(); + } + + @Bean(name = "sapCallExecutor") + public Executor sapCallExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(4); + executor.setMaxPoolSize(8); + executor.setQueueCapacity(32); + executor.setThreadNamePrefix("sap-call-"); + executor.setAllowCoreThreadTimeOut(true); + executor.initialize(); + return executor; + } + + /** + * Adds {@code APIKey} (sandbox) or {@code Authorization: Bearer} (production) + * plus a standard {@code Accept: application/json} on every outbound call. + */ + static final class SapAuthInterceptor implements ClientHttpRequestInterceptor { + private final String apiKey; + private final String bearerToken; + + SapAuthInterceptor(String apiKey, String bearerToken) { + this.apiKey = apiKey; + this.bearerToken = bearerToken; + } + + @Override + public ClientHttpResponse intercept(HttpRequest request, byte[] body, + ClientHttpRequestExecution execution) throws IOException { + HttpHeaders headers = request.getHeaders(); + if (!bearerToken.isBlank()) { + headers.set(HttpHeaders.AUTHORIZATION, "Bearer " + bearerToken); + } else if (!apiKey.isBlank()) { + headers.set("APIKey", apiKey); + } + if (!headers.containsKey(HttpHeaders.ACCEPT)) { + headers.set(HttpHeaders.ACCEPT, "application/json"); + } + return execution.execute(request, body); + } + } + +} diff --git a/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/model/BusinessPartner.java b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/model/BusinessPartner.java new file mode 100644 index 00000000..f408c732 --- /dev/null +++ b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/model/BusinessPartner.java @@ -0,0 +1,99 @@ +package com.tricentisdemo.sap.customer360.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Maps a subset of SAP's {@code A_BusinessPartner} entity. + * + *

SAP returns ~80 fields on this entity; we project only the ones this + * service consumes. {@link JsonIgnoreProperties} is intentionally set to + * {@code ignoreUnknown = true} so a downstream addition of a new field by + * SAP doesn't break this client — but a removal or rename + * of a consumed field will surface as a missing value during + * {@code keploy test}, which is exactly the contract-drift signal + * Tricentis customers need after quarterly S/4HANA updates. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class BusinessPartner { + + @JsonProperty("BusinessPartner") + private String businessPartner; + + @JsonProperty("BusinessPartnerCategory") + private String category; + + @JsonProperty("BusinessPartnerFullName") + private String fullName; + + @JsonProperty("BusinessPartnerGrouping") + private String grouping; + + @JsonProperty("FirstName") + private String firstName; + + @JsonProperty("LastName") + private String lastName; + + @JsonProperty("OrganizationBPName1") + private String organizationName; + + @JsonProperty("CreatedByUser") + private String createdBy; + + @JsonProperty("CreationDate") + private String createdDate; + + @JsonProperty("LastChangedByUser") + private String lastChangedBy; + + @JsonProperty("LastChangeDate") + private String lastChangeDate; + + @JsonProperty("BusinessPartnerIsBlocked") + private Boolean blocked; + + @JsonProperty("ETag") + private String etag; + + // ---- accessors --------------------------------------------------------- + + public String getBusinessPartner() { return businessPartner; } + public void setBusinessPartner(String v) { this.businessPartner = v; } + + public String getCategory() { return category; } + public void setCategory(String v) { this.category = v; } + + public String getFullName() { return fullName; } + public void setFullName(String v) { this.fullName = v; } + + public String getGrouping() { return grouping; } + public void setGrouping(String v) { this.grouping = v; } + + public String getFirstName() { return firstName; } + public void setFirstName(String v) { this.firstName = v; } + + public String getLastName() { return lastName; } + public void setLastName(String v) { this.lastName = v; } + + public String getOrganizationName() { return organizationName; } + public void setOrganizationName(String v) { this.organizationName = v; } + + public String getCreatedBy() { return createdBy; } + public void setCreatedBy(String v) { this.createdBy = v; } + + public String getCreatedDate() { return createdDate; } + public void setCreatedDate(String v) { this.createdDate = v; } + + public String getLastChangedBy() { return lastChangedBy; } + public void setLastChangedBy(String v) { this.lastChangedBy = v; } + + public String getLastChangeDate() { return lastChangeDate; } + public void setLastChangeDate(String v) { this.lastChangeDate = v; } + + public Boolean getBlocked() { return blocked; } + public void setBlocked(Boolean v) { this.blocked = v; } + + public String getEtag() { return etag; } + public void setEtag(String v) { this.etag = v; } +} diff --git a/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/model/BusinessPartnerAddress.java b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/model/BusinessPartnerAddress.java new file mode 100644 index 00000000..c9ebf67c --- /dev/null +++ b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/model/BusinessPartnerAddress.java @@ -0,0 +1,75 @@ +package com.tricentisdemo.sap.customer360.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Subset of SAP's {@code A_BusinessPartnerAddress} entity used by the 360 view. + * One business partner typically has 1..N addresses (billing, shipping, + * registered office, etc.) keyed by {@code AddressID}. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class BusinessPartnerAddress { + + @JsonProperty("BusinessPartner") + private String businessPartner; + + @JsonProperty("AddressID") + private String addressId; + + @JsonProperty("ValidityStartDate") + private String validFrom; + + @JsonProperty("ValidityEndDate") + private String validTo; + + @JsonProperty("StreetName") + private String street; + + @JsonProperty("HouseNumber") + private String houseNumber; + + @JsonProperty("CityName") + private String city; + + @JsonProperty("PostalCode") + private String postalCode; + + @JsonProperty("Country") + private String country; + + @JsonProperty("Region") + private String region; + + // ---- accessors --------------------------------------------------------- + + public String getBusinessPartner() { return businessPartner; } + public void setBusinessPartner(String v) { this.businessPartner = v; } + + public String getAddressId() { return addressId; } + public void setAddressId(String v) { this.addressId = v; } + + public String getValidFrom() { return validFrom; } + public void setValidFrom(String v) { this.validFrom = v; } + + public String getValidTo() { return validTo; } + public void setValidTo(String v) { this.validTo = v; } + + public String getStreet() { return street; } + public void setStreet(String v) { this.street = v; } + + public String getHouseNumber() { return houseNumber; } + public void setHouseNumber(String v) { this.houseNumber = v; } + + public String getCity() { return city; } + public void setCity(String v) { this.city = v; } + + public String getPostalCode() { return postalCode; } + public void setPostalCode(String v) { this.postalCode = v; } + + public String getCountry() { return country; } + public void setCountry(String v) { this.country = v; } + + public String getRegion() { return region; } + public void setRegion(String v) { this.region = v; } +} diff --git a/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/model/BusinessPartnerRole.java b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/model/BusinessPartnerRole.java new file mode 100644 index 00000000..d89378bb --- /dev/null +++ b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/model/BusinessPartnerRole.java @@ -0,0 +1,38 @@ +package com.tricentisdemo.sap.customer360.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Subset of SAP's {@code A_BusinessPartnerRole}. A business partner can act + * in several roles (customer {@code FLCU00}, supplier {@code FLVN00}, etc.) + * simultaneously; this is the classic "one master record, many roles" + * pattern at the heart of SAP master data. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class BusinessPartnerRole { + + @JsonProperty("BusinessPartner") + private String businessPartner; + + @JsonProperty("BusinessPartnerRole") + private String roleCode; + + @JsonProperty("ValidFrom") + private String validFrom; + + @JsonProperty("ValidTo") + private String validTo; + + public String getBusinessPartner() { return businessPartner; } + public void setBusinessPartner(String v) { this.businessPartner = v; } + + public String getRoleCode() { return roleCode; } + public void setRoleCode(String v) { this.roleCode = v; } + + public String getValidFrom() { return validFrom; } + public void setValidFrom(String v) { this.validFrom = v; } + + public String getValidTo() { return validTo; } + public void setValidTo(String v) { this.validTo = v; } +} diff --git a/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/model/Customer360View.java b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/model/Customer360View.java new file mode 100644 index 00000000..046dde6a --- /dev/null +++ b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/model/Customer360View.java @@ -0,0 +1,64 @@ +package com.tricentisdemo.sap.customer360.model; + +import com.tricentisdemo.sap.customer360.persistence.CustomerNote; +import com.tricentisdemo.sap.customer360.persistence.CustomerTag; + +import java.time.Instant; +import java.util.List; + +/** + * The aggregated "Customer 360" response returned to downstream consumers. + * + *

Produced by + * {@link com.tricentisdemo.sap.customer360.service.Customer360AggregatorService} + * from three parallel SAP OData calls + two parallel Postgres queries + + * one audit INSERT. This composite shape is the kind of payload + * downstream CRM / portal / analytics pipelines consume in typical RISE + * with SAP BTP landscapes. + */ +public class Customer360View { + + private String customerId; + private BusinessPartner partner; + private List addresses; + private List roles; + private List tags; + private List notes; + private Instant aggregatedAt; + private String correlationId; + private String dataSource; + private Integer elapsedMs; + + public Customer360View() { + } + + public String getCustomerId() { return customerId; } + public void setCustomerId(String v) { this.customerId = v; } + + public BusinessPartner getPartner() { return partner; } + public void setPartner(BusinessPartner v) { this.partner = v; } + + public List getAddresses() { return addresses; } + public void setAddresses(List v) { this.addresses = v; } + + public List getRoles() { return roles; } + public void setRoles(List v) { this.roles = v; } + + public List getTags() { return tags; } + public void setTags(List v) { this.tags = v; } + + public List getNotes() { return notes; } + public void setNotes(List v) { this.notes = v; } + + public Integer getElapsedMs() { return elapsedMs; } + public void setElapsedMs(Integer v) { this.elapsedMs = v; } + + public Instant getAggregatedAt() { return aggregatedAt; } + public void setAggregatedAt(Instant v) { this.aggregatedAt = v; } + + public String getCorrelationId() { return correlationId; } + public void setCorrelationId(String v) { this.correlationId = v; } + + public String getDataSource() { return dataSource; } + public void setDataSource(String v) { this.dataSource = v; } +} diff --git a/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/model/CustomerSummary.java b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/model/CustomerSummary.java new file mode 100644 index 00000000..d7a66ca1 --- /dev/null +++ b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/model/CustomerSummary.java @@ -0,0 +1,52 @@ +package com.tricentisdemo.sap.customer360.model; + +/** + * Flat, list-friendly projection of a {@link BusinessPartner} for the + * {@code GET /api/v1/customers} paged list endpoint. Keeps payloads small + * for UI tables that only need an at-a-glance view. + */ +public class CustomerSummary { + + private String id; + private String name; + private String category; + private boolean blocked; + + public CustomerSummary() { + } + + public CustomerSummary(String id, String name, String category, boolean blocked) { + this.id = id; + this.name = name; + this.category = category; + this.blocked = blocked; + } + + public static CustomerSummary from(BusinessPartner bp) { + String displayName = bp.getFullName(); + if (displayName == null || displayName.isBlank()) { + displayName = bp.getOrganizationName(); + } + if (displayName == null || displayName.isBlank()) { + displayName = (bp.getFirstName() + " " + bp.getLastName()).trim(); + } + return new CustomerSummary( + bp.getBusinessPartner(), + displayName, + bp.getCategory(), + Boolean.TRUE.equals(bp.getBlocked()) + ); + } + + public String getId() { return id; } + public void setId(String v) { this.id = v; } + + public String getName() { return name; } + public void setName(String v) { this.name = v; } + + public String getCategory() { return category; } + public void setCategory(String v) { this.category = v; } + + public boolean isBlocked() { return blocked; } + public void setBlocked(boolean v) { this.blocked = v; } +} diff --git a/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/model/NoteRequest.java b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/model/NoteRequest.java new file mode 100644 index 00000000..d8481ffd --- /dev/null +++ b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/model/NoteRequest.java @@ -0,0 +1,24 @@ +package com.tricentisdemo.sap.customer360.model; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * Request body for {@code POST /api/v1/customers/{id}/notes}. + */ +public class NoteRequest { + + @NotBlank + @Size(min = 1, max = 2000) + private String body; + + private String author; + + public NoteRequest() {} + + public String getBody() { return body; } + public void setBody(String v) { this.body = v; } + + public String getAuthor() { return author; } + public void setAuthor(String v) { this.author = v; } +} diff --git a/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/model/ODataCollectionResponse.java b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/model/ODataCollectionResponse.java new file mode 100644 index 00000000..b753e75b --- /dev/null +++ b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/model/ODataCollectionResponse.java @@ -0,0 +1,75 @@ +package com.tricentisdemo.sap.customer360.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +/** + * Envelope for an SAP OData v2 collection response: + * + *

+ * {
+ *   "d": {
+ *     "results": [ { ... }, { ... } ],
+ *     "__count": "42",
+ *     "__next": "...pagination link..."
+ *   }
+ * }
+ * 
+ */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class ODataCollectionResponse { + + @JsonProperty("d") + private Data data; + + public Data getData() { + return data; + } + + public void setData(Data data) { + this.data = data; + } + + public List getResults() { + return data == null ? List.of() : data.getResults(); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Data { + + @JsonProperty("results") + private List results; + + @JsonProperty("__count") + private String count; + + @JsonProperty("__next") + private String nextLink; + + public List getResults() { + return results == null ? List.of() : results; + } + + public void setResults(List results) { + this.results = results; + } + + public String getCount() { + return count; + } + + public void setCount(String count) { + this.count = count; + } + + public String getNextLink() { + return nextLink; + } + + public void setNextLink(String nextLink) { + this.nextLink = nextLink; + } + } +} diff --git a/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/model/ODataEntityResponse.java b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/model/ODataEntityResponse.java new file mode 100644 index 00000000..e98c9ffb --- /dev/null +++ b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/model/ODataEntityResponse.java @@ -0,0 +1,35 @@ +package com.tricentisdemo.sap.customer360.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Generic envelope for an SAP OData v2 single-entity response: + * + *
+ * { "d": { ...entity fields... } }
+ * 
+ * + * The {@code d} wrapper is SAP's OData v2 convention. OData v4 drops it and + * returns the entity at the root — when this service is migrated to v4 APIs + * (e.g., {@code /API_BUSINESS_PARTNER_SRV/A_BusinessPartner('11')} in + * S/4HANA Cloud), this envelope goes away. That shift is the exact kind of + * change a Tricentis migration customer has to regression-test. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class ODataEntityResponse { + + @JsonProperty("d") + private T entity; + + public ODataEntityResponse() { + } + + public T getEntity() { + return entity; + } + + public void setEntity(T entity) { + this.entity = entity; + } +} diff --git a/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/model/ProblemResponse.java b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/model/ProblemResponse.java new file mode 100644 index 00000000..3ad05c01 --- /dev/null +++ b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/model/ProblemResponse.java @@ -0,0 +1,57 @@ +package com.tricentisdemo.sap.customer360.model; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import java.time.Instant; + +/** + * RFC 7807 Problem Details for HTTP APIs response body. + * + *

Returned by {@link com.tricentisdemo.sap.customer360.web.GlobalExceptionHandler} + * for any unhandled exception surfaced by the service. Stable shape so that + * downstream consumers (and Keploy mock diffs) can rely on it. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ProblemResponse { + + private String type; + private String title; + private int status; + private String detail; + private String instance; + private String correlationId; + private Integer upstreamStatus; + private String sapErrorCode; + private Instant timestamp; + + public ProblemResponse() { + this.timestamp = Instant.now(); + } + + public String getType() { return type; } + public void setType(String v) { this.type = v; } + + public String getTitle() { return title; } + public void setTitle(String v) { this.title = v; } + + public int getStatus() { return status; } + public void setStatus(int v) { this.status = v; } + + public String getDetail() { return detail; } + public void setDetail(String v) { this.detail = v; } + + public String getInstance() { return instance; } + public void setInstance(String v) { this.instance = v; } + + public String getCorrelationId() { return correlationId; } + public void setCorrelationId(String v) { this.correlationId = v; } + + public Integer getUpstreamStatus() { return upstreamStatus; } + public void setUpstreamStatus(Integer v) { this.upstreamStatus = v; } + + public String getSapErrorCode() { return sapErrorCode; } + public void setSapErrorCode(String v) { this.sapErrorCode = v; } + + public Instant getTimestamp() { return timestamp; } + public void setTimestamp(Instant v) { this.timestamp = v; } +} diff --git a/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/model/TagRequest.java b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/model/TagRequest.java new file mode 100644 index 00000000..088310cd --- /dev/null +++ b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/model/TagRequest.java @@ -0,0 +1,26 @@ +package com.tricentisdemo.sap.customer360.model; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +/** + * Request body for {@code POST /api/v1/customers/{id}/tags}. + */ +public class TagRequest { + + @NotBlank + @Size(min = 1, max = 64) + @Pattern(regexp = "^[a-zA-Z0-9_.\\-]{1,64}$", message = "tag must match [a-zA-Z0-9_.-]{1,64}") + private String tag; + + private String createdBy; + + public TagRequest() {} + + public String getTag() { return tag; } + public void setTag(String v) { this.tag = v; } + + public String getCreatedBy() { return createdBy; } + public void setCreatedBy(String v) { this.createdBy = v; } +} diff --git a/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/persistence/AuditEvent.java b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/persistence/AuditEvent.java new file mode 100644 index 00000000..c4249248 --- /dev/null +++ b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/persistence/AuditEvent.java @@ -0,0 +1,65 @@ +package com.tricentisdemo.sap.customer360.persistence; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.time.Instant; + +/** + * One row per service operation — the compliance audit trail. + * + *

Written synchronously as part of the aggregator flow so the INSERT + * statement shows up in Keploy's Postgres wire-protocol capture on the + * same path as the SAP HTTP GETs. If you reorder this to an async write, + * Keploy replays may race against the test runner. + */ +@Entity +@Table(name = "audit_event") +public class AuditEvent { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "customer_id", length = 10) + private String customerId; + + @Column(nullable = false, length = 64) + private String operation; + + @Column(name = "correlation_id", length = 128) + private String correlationId; + + @Column(name = "latency_ms") + private Integer latencyMs; + + @Column(name = "happened_at", nullable = false, updatable = false) + private Instant happenedAt; + + public AuditEvent() {} + + public AuditEvent(String customerId, String operation, String correlationId, Integer latencyMs) { + this.customerId = customerId; + this.operation = operation; + this.correlationId = correlationId; + this.latencyMs = latencyMs; + this.happenedAt = Instant.now(); + } + + public Long getId() { return id; } + public void setId(Long v) { this.id = v; } + public String getCustomerId() { return customerId; } + public void setCustomerId(String v) { this.customerId = v; } + public String getOperation() { return operation; } + public void setOperation(String v) { this.operation = v; } + public String getCorrelationId() { return correlationId; } + public void setCorrelationId(String v) { this.correlationId = v; } + public Integer getLatencyMs() { return latencyMs; } + public void setLatencyMs(Integer v) { this.latencyMs = v; } + public Instant getHappenedAt() { return happenedAt; } + public void setHappenedAt(Instant v) { this.happenedAt = v; } +} diff --git a/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/persistence/CustomerNote.java b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/persistence/CustomerNote.java new file mode 100644 index 00000000..8a497fb5 --- /dev/null +++ b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/persistence/CustomerNote.java @@ -0,0 +1,55 @@ +package com.tricentisdemo.sap.customer360.persistence; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.time.Instant; + +/** + * Free-text note captured on a customer (e.g. during a CSR call). Multiple + * notes allowed per customer; ordered by created_at for display. + */ +@Entity +@Table(name = "customer_note") +public class CustomerNote { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "customer_id", nullable = false, length = 10) + private String customerId; + + @Column(nullable = false, columnDefinition = "TEXT") + private String body; + + @Column(nullable = false, length = 64) + private String author; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + public CustomerNote() {} + + public CustomerNote(String customerId, String body, String author) { + this.customerId = customerId; + this.body = body; + this.author = author; + this.createdAt = Instant.now(); + } + + public Long getId() { return id; } + public void setId(Long v) { this.id = v; } + public String getCustomerId() { return customerId; } + public void setCustomerId(String v) { this.customerId = v; } + public String getBody() { return body; } + public void setBody(String v) { this.body = v; } + public String getAuthor() { return author; } + public void setAuthor(String v) { this.author = v; } + public Instant getCreatedAt() { return createdAt; } + public void setCreatedAt(Instant v) { this.createdAt = v; } +} diff --git a/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/persistence/CustomerTag.java b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/persistence/CustomerTag.java new file mode 100644 index 00000000..fa9564e8 --- /dev/null +++ b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/persistence/CustomerTag.java @@ -0,0 +1,58 @@ +package com.tricentisdemo.sap.customer360.persistence; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +import java.time.Instant; + +/** + * User-assigned label attached to a customer (BP). Unique per (customer_id, tag). + */ +@Entity +@Table( + name = "customer_tag", + uniqueConstraints = @UniqueConstraint(name = "uk_customer_tag", columnNames = {"customer_id", "tag"}) +) +public class CustomerTag { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "customer_id", nullable = false, length = 10) + private String customerId; + + @Column(nullable = false, length = 64) + private String tag; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "created_by", nullable = false, length = 64) + private String createdBy; + + public CustomerTag() {} + + public CustomerTag(String customerId, String tag, String createdBy) { + this.customerId = customerId; + this.tag = tag; + this.createdBy = createdBy; + this.createdAt = Instant.now(); + } + + public Long getId() { return id; } + public void setId(Long v) { this.id = v; } + public String getCustomerId() { return customerId; } + public void setCustomerId(String v) { this.customerId = v; } + public String getTag() { return tag; } + public void setTag(String v) { this.tag = v; } + public Instant getCreatedAt() { return createdAt; } + public void setCreatedAt(Instant v) { this.createdAt = v; } + public String getCreatedBy() { return createdBy; } + public void setCreatedBy(String v) { this.createdBy = v; } +} diff --git a/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/repository/AuditEventRepository.java b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/repository/AuditEventRepository.java new file mode 100644 index 00000000..dc1166a8 --- /dev/null +++ b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/repository/AuditEventRepository.java @@ -0,0 +1,15 @@ +package com.tricentisdemo.sap.customer360.repository; + +import com.tricentisdemo.sap.customer360.persistence.AuditEvent; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface AuditEventRepository extends JpaRepository { + + List findTop50ByOrderByHappenedAtDesc(); + + List findTop20ByCustomerIdOrderByHappenedAtDesc(String customerId); +} diff --git a/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/repository/CustomerNoteRepository.java b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/repository/CustomerNoteRepository.java new file mode 100644 index 00000000..c34e5f6d --- /dev/null +++ b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/repository/CustomerNoteRepository.java @@ -0,0 +1,15 @@ +package com.tricentisdemo.sap.customer360.repository; + +import com.tricentisdemo.sap.customer360.persistence.CustomerNote; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface CustomerNoteRepository extends JpaRepository { + + List findAllByCustomerIdOrderByCreatedAtDesc(String customerId); + + long countByCustomerId(String customerId); +} diff --git a/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/repository/CustomerTagRepository.java b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/repository/CustomerTagRepository.java new file mode 100644 index 00000000..f0bad274 --- /dev/null +++ b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/repository/CustomerTagRepository.java @@ -0,0 +1,24 @@ +package com.tricentisdemo.sap.customer360.repository; + +import com.tricentisdemo.sap.customer360.persistence.CustomerTag; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Repository +public interface CustomerTagRepository extends JpaRepository { + + List findAllByCustomerIdOrderByCreatedAtDesc(String customerId); + + boolean existsByCustomerIdAndTag(String customerId, String tag); + + @Modifying + @Transactional + @Query("DELETE FROM CustomerTag t WHERE t.customerId = :customerId AND t.tag = :tag") + int deleteByCustomerIdAndTag(@Param("customerId") String customerId, @Param("tag") String tag); +} diff --git a/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/sap/CorrelationIdFilter.java b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/sap/CorrelationIdFilter.java new file mode 100644 index 00000000..a66e9ad3 --- /dev/null +++ b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/sap/CorrelationIdFilter.java @@ -0,0 +1,48 @@ +package com.tricentisdemo.sap.customer360.sap; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.MDC; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.UUID; + +/** + * Inbound correlation-id filter. Runs first in the filter chain. + * + *

If the caller supplies an {@code X-Correlation-ID} header (typical for + * requests routed through an API gateway or upstream BTP service), we honour + * it. Otherwise we mint a new one. Either way, it lands in MDC for the + * duration of the request so every log line carries it, and is echoed back + * on the response so the caller can correlate on their side. + */ +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class CorrelationIdFilter extends OncePerRequestFilter { + + public static final String MDC_KEY = CorrelationIdInterceptor.MDC_KEY; + public static final String HEADER = CorrelationIdInterceptor.HEADER; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain chain) throws ServletException, IOException { + String correlationId = request.getHeader(HEADER); + if (correlationId == null || correlationId.isBlank()) { + correlationId = UUID.randomUUID().toString(); + } + MDC.put(MDC_KEY, correlationId); + response.setHeader(HEADER, correlationId); + try { + chain.doFilter(request, response); + } finally { + MDC.remove(MDC_KEY); + } + } +} diff --git a/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/sap/CorrelationIdInterceptor.java b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/sap/CorrelationIdInterceptor.java new file mode 100644 index 00000000..b8c8198e --- /dev/null +++ b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/sap/CorrelationIdInterceptor.java @@ -0,0 +1,39 @@ +package com.tricentisdemo.sap.customer360.sap; + +import org.slf4j.MDC; +import org.springframework.http.HttpRequest; +import org.springframework.http.client.ClientHttpRequestExecution; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.client.ClientHttpResponse; + +import java.io.IOException; +import java.util.UUID; + +/** + * Propagates the caller's correlation id into every outbound SAP call. + * + *

For incoming requests, {@link com.tricentisdemo.sap.customer360.sap.CorrelationIdFilter} + * seeds the MDC. This interceptor reads it back and sets the + * {@code X-Correlation-ID} header on the SAP call so distributed traces + * chain across the hop — operationally critical in BTP landscapes where a + * single business request can trigger calls to five or more backends. + * + *

If MDC is empty (e.g., a scheduled job), a fresh id is generated so + * the SAP side always sees something. + */ +public class CorrelationIdInterceptor implements ClientHttpRequestInterceptor { + + public static final String MDC_KEY = "correlationId"; + public static final String HEADER = "X-Correlation-ID"; + + @Override + public ClientHttpResponse intercept(HttpRequest request, byte[] body, + ClientHttpRequestExecution execution) throws IOException { + String correlationId = MDC.get(MDC_KEY); + if (correlationId == null || correlationId.isBlank()) { + correlationId = UUID.randomUUID().toString(); + } + request.getHeaders().set(HEADER, correlationId); + return execution.execute(request, body); + } +} diff --git a/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/sap/SapApiException.java b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/sap/SapApiException.java new file mode 100644 index 00000000..6a6f35e5 --- /dev/null +++ b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/sap/SapApiException.java @@ -0,0 +1,68 @@ +package com.tricentisdemo.sap.customer360.sap; + +import org.springframework.http.HttpStatus; + +/** + * Application-level exception that translates SAP-side failures into something + * the Spring MVC layer can map to a meaningful HTTP status. + * + *

Thrown by the {@link SapBusinessPartnerClient} when: + *

    + *
  • SAP returns a 4xx/5xx response (status preserved verbatim)
  • + *
  • A transport-level error occurs (connect timeout, read timeout, + * TLS failure — mapped to 502 Bad Gateway)
  • + *
  • A response body cannot be deserialised to the expected model + * (mapped to 502 Bad Gateway with a schema-drift hint)
  • + *
+ * + *

The {@link #upstreamStatus} field preserves the exact status SAP returned + * so {@link com.tricentisdemo.sap.customer360.web.GlobalExceptionHandler} + * can surface it in an RFC 7807 problem response as + * {@code X-Upstream-Status}. Keploy captures this header verbatim in the + * replayed mocks, which lets contract-diff checks catch status regressions + * even when the body hasn't changed. + */ +public class SapApiException extends RuntimeException { + + private final HttpStatus upstreamStatus; + private final String sapErrorCode; + + /** + * Minimal constructor — upstream HTTP status + message only. + * Use this for SAP-returned 4xx/5xx errors where no further code or + * underlying cause is available. + */ + public SapApiException(HttpStatus upstreamStatus, String message) { + super(message); + this.upstreamStatus = upstreamStatus; + this.sapErrorCode = null; + } + + /** + * With SAP error code (from the OData error envelope, e.g. + * {@code "error.code": "SY/530"}) for richer client diagnostics. + */ + public SapApiException(HttpStatus upstreamStatus, String sapErrorCode, String message) { + super(message); + this.upstreamStatus = upstreamStatus; + this.sapErrorCode = sapErrorCode; + } + + /** + * For transport-level failures that wrap an underlying cause + * (IOException, deserialisation failure, etc.). + */ + public SapApiException(HttpStatus upstreamStatus, String message, Throwable cause) { + super(message, cause); + this.upstreamStatus = upstreamStatus; + this.sapErrorCode = null; + } + + public HttpStatus getUpstreamStatus() { + return upstreamStatus; + } + + public String getSapErrorCode() { + return sapErrorCode; + } +} diff --git a/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/sap/SapBusinessPartnerClient.java b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/sap/SapBusinessPartnerClient.java new file mode 100644 index 00000000..5afa1ccf --- /dev/null +++ b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/sap/SapBusinessPartnerClient.java @@ -0,0 +1,250 @@ +package com.tricentisdemo.sap.customer360.sap; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.tricentisdemo.sap.customer360.model.BusinessPartner; +import com.tricentisdemo.sap.customer360.model.BusinessPartnerAddress; +import com.tricentisdemo.sap.customer360.model.BusinessPartnerRole; +import com.tricentisdemo.sap.customer360.model.ODataCollectionResponse; +import com.tricentisdemo.sap.customer360.model.ODataEntityResponse; +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import io.github.resilience4j.retry.annotation.Retry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpStatusCodeException; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestTemplate; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.List; + +/** + * Low-level gateway to SAP's {@code API_BUSINESS_PARTNER} OData service. + * + *

All outbound SAP calls go through here. Each method: + *

    + *
  • Has a Resilience4j {@link Retry @Retry} and + * {@link CircuitBreaker @CircuitBreaker} annotation so transient + * 5xx / timeout failures don't blow up the caller
  • + *
  • Translates HTTP layer exceptions into domain-level + * {@link SapApiException}
  • + *
  • Logs every call at INFO with method + path + correlation id, + * including final status for traceability
  • + *
+ * + *

The service path is fixed; only the sub-path varies. Base URL is set + * on the RestTemplate's rootUri (see + * {@link com.tricentisdemo.sap.customer360.config.SapClientConfig}). + */ +@Component +public class SapBusinessPartnerClient { + + private static final Logger log = LoggerFactory.getLogger(SapBusinessPartnerClient.class); + + private static final String BP_SERVICE = "/sap/opu/odata/sap/API_BUSINESS_PARTNER"; + private static final String ENTITY_SET_PARTNER = "/A_BusinessPartner"; + // Nav properties — preferred for fetching a partner's child collections. + // SAP's sandbox rejects $filter on the top-level child entity sets. + private static final String NAV_ADDRESSES = "/to_BusinessPartnerAddress"; + private static final String NAV_ROLES = "/to_BusinessPartnerRole"; + + private final RestTemplate sapRestTemplate; + private final ObjectMapper objectMapper; + + @Value("${sap.api.default-top:10}") + private int defaultTop; + + public SapBusinessPartnerClient(RestTemplate sapRestTemplate, ObjectMapper objectMapper) { + this.sapRestTemplate = sapRestTemplate; + this.objectMapper = objectMapper; + } + + // ----------------------------------------------------------------------- + // Single entity reads + // ----------------------------------------------------------------------- + + @Retry(name = "sapApi") + @CircuitBreaker(name = "sapApi") + @Cacheable(value = "sap.partner", key = "#businessPartnerId") + public BusinessPartner fetchPartner(String businessPartnerId) { + String path = BP_SERVICE + ENTITY_SET_PARTNER + + "('" + urlEncode(businessPartnerId) + "')?$format=json"; + log.info("SAP GET partner id={} path={}", businessPartnerId, path); + + String raw = exchangeForString(path); + try { + ODataEntityResponse wrapper = objectMapper.readValue( + raw, new TypeReference>() {}); + if (wrapper == null || wrapper.getEntity() == null) { + throw new SapApiException( + org.springframework.http.HttpStatus.BAD_GATEWAY, + "SAP response did not contain a d.entity element; schema drift?"); + } + return wrapper.getEntity(); + } catch (IOException e) { + throw new SapApiException( + org.springframework.http.HttpStatus.BAD_GATEWAY, + "Failed to parse SAP business partner response: " + e.getMessage(), e); + } + } + + // ----------------------------------------------------------------------- + // Collection reads + // ----------------------------------------------------------------------- + + @Retry(name = "sapApi") + @CircuitBreaker(name = "sapApi") + @Cacheable(value = "sap.partners-page", key = "#top + '-' + #skip") + public List listPartners(int top, int skip) { + int safeTop = top <= 0 ? defaultTop : Math.min(top, 100); + int safeSkip = Math.max(skip, 0); + + String path = BP_SERVICE + ENTITY_SET_PARTNER + + "?$top=" + safeTop + + "&$skip=" + safeSkip + + "&$format=json"; + log.info("SAP GET partners top={} skip={}", safeTop, safeSkip); + + String raw = exchangeForString(path); + try { + ODataCollectionResponse wrapper = objectMapper.readValue( + raw, new TypeReference>() {}); + return wrapper.getResults(); + } catch (IOException e) { + throw new SapApiException( + org.springframework.http.HttpStatus.BAD_GATEWAY, + "Failed to parse SAP business partner list: " + e.getMessage(), e); + } + } + + @Retry(name = "sapApi") + @CircuitBreaker(name = "sapApi") + @Cacheable(value = "sap.addresses", key = "#businessPartnerId") + public List fetchAddresses(String businessPartnerId) { + String path = BP_SERVICE + ENTITY_SET_PARTNER + + "('" + urlEncode(businessPartnerId) + "')" + + NAV_ADDRESSES + + "?$format=json&$top=50"; + log.info("SAP GET addresses for bp={}", businessPartnerId); + + String raw = exchangeForString(path); + try { + ODataCollectionResponse wrapper = objectMapper.readValue( + raw, new TypeReference>() {}); + return wrapper.getResults(); + } catch (IOException e) { + throw new SapApiException( + org.springframework.http.HttpStatus.BAD_GATEWAY, + "Failed to parse SAP addresses response: " + e.getMessage(), e); + } + } + + @Retry(name = "sapApi") + @CircuitBreaker(name = "sapApi") + @Cacheable(value = "sap.roles", key = "#businessPartnerId") + public List fetchRoles(String businessPartnerId) { + String path = BP_SERVICE + ENTITY_SET_PARTNER + + "('" + urlEncode(businessPartnerId) + "')" + + NAV_ROLES + + "?$format=json&$top=50"; + log.info("SAP GET roles for bp={}", businessPartnerId); + + String raw = exchangeForString(path); + try { + ODataCollectionResponse wrapper = objectMapper.readValue( + raw, new TypeReference>() {}); + return wrapper.getResults(); + } catch (IOException e) { + throw new SapApiException( + org.springframework.http.HttpStatus.BAD_GATEWAY, + "Failed to parse SAP roles response: " + e.getMessage(), e); + } + } + + // ----------------------------------------------------------------------- + // Aggregate reads + // ----------------------------------------------------------------------- + + @Retry(name = "sapApi") + @CircuitBreaker(name = "sapApi") + @Cacheable(value = "sap.count") + public long fetchTotalCount() { + String path = BP_SERVICE + ENTITY_SET_PARTNER + "/$count"; + log.info("SAP GET $count"); + String raw = exchangeForString(path); + try { + return Long.parseLong(raw.trim()); + } catch (NumberFormatException e) { + throw new SapApiException( + org.springframework.http.HttpStatus.BAD_GATEWAY, + "SAP $count endpoint returned non-numeric response: '" + raw + "'", e); + } + } + + // ----------------------------------------------------------------------- + // Shared plumbing + // ----------------------------------------------------------------------- + + private String exchangeForString(String path) { + try { + // NOTE: pass the path as a String (not a URI) so RestTemplateBuilder's + // rootUri is prefixed. Passing a URI instance bypasses the root and + // the path — being relative — blows up with "URI is not absolute". + ResponseEntity response = sapRestTemplate.getForEntity( + path, String.class); + HttpStatusCode status = response.getStatusCode(); + if (!status.is2xxSuccessful()) { + throw new SapApiException( + org.springframework.http.HttpStatus.valueOf(status.value()), + "SAP returned non-2xx: " + status.value()); + } + return response.getBody() != null ? response.getBody() : ""; + } catch (HttpStatusCodeException upstream) { + log.warn("SAP upstream error status={} path={} body={}", + upstream.getStatusCode(), path, + truncate(upstream.getResponseBodyAsString(), 500)); + throw new SapApiException( + org.springframework.http.HttpStatus.valueOf(upstream.getStatusCode().value()), + "SAP upstream error: " + upstream.getStatusText(), + upstream); + } catch (ResourceAccessException transport) { + log.warn("SAP transport error path={} cause={}", path, + transport.getMessage()); + throw new SapApiException( + org.springframework.http.HttpStatus.BAD_GATEWAY, + "SAP transport error: " + transport.getMessage(), + transport); + } + } + + private static String urlEncode(String v) { + return URLEncoder.encode(v, StandardCharsets.UTF_8); + } + + /** + * OData-compatible URL encoding for {@code $filter} values. + * + *

The stdlib {@link URLEncoder} is form-encoded: it emits {@code +} + * for spaces and {@code %27} for single quotes. SAP's OData v2 parser + * accepts {@code %20} for spaces but refuses {@code +} ("Invalid token + * detected at position N"), and expects literal single quotes around + * string literals, not percent-encoded. This method fixes both. + */ + private static String odataEncode(String v) { + return URLEncoder.encode(v, StandardCharsets.UTF_8) + .replace("+", "%20") + .replace("%27", "'"); + } + + private static String truncate(String s, int max) { + if (s == null) return ""; + return s.length() <= max ? s : s.substring(0, max) + "...(truncated)"; + } +} diff --git a/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/service/AuditService.java b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/service/AuditService.java new file mode 100644 index 00000000..0a76b123 --- /dev/null +++ b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/service/AuditService.java @@ -0,0 +1,47 @@ +package com.tricentisdemo.sap.customer360.service; + +import com.tricentisdemo.sap.customer360.persistence.AuditEvent; +import com.tricentisdemo.sap.customer360.repository.AuditEventRepository; +import com.tricentisdemo.sap.customer360.sap.CorrelationIdInterceptor; +import org.slf4j.MDC; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * Writes one row per service operation for compliance audit + usage analytics. + * + *

Written synchronously (no {@code @Async}) so the INSERT statement lands + * in Keploy's captured wire-protocol log on the same request path as the + * outbound SAP GETs. That determinism matters for replay. + */ +@Service +public class AuditService { + + private final AuditEventRepository repo; + + public AuditService(AuditEventRepository repo) { + this.repo = repo; + } + + @Transactional + public AuditEvent record(String customerId, String operation, Integer latencyMs) { + return repo.save(new AuditEvent( + customerId, + operation, + MDC.get(CorrelationIdInterceptor.MDC_KEY), + latencyMs + )); + } + + @Transactional(readOnly = true) + public List recent() { + return repo.findTop50ByOrderByHappenedAtDesc(); + } + + @Transactional(readOnly = true) + public List recentForCustomer(String customerId) { + return repo.findTop20ByCustomerIdOrderByHappenedAtDesc(customerId); + } +} diff --git a/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/service/Customer360AggregatorService.java b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/service/Customer360AggregatorService.java new file mode 100644 index 00000000..b259bada --- /dev/null +++ b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/service/Customer360AggregatorService.java @@ -0,0 +1,181 @@ +package com.tricentisdemo.sap.customer360.service; + +import com.tricentisdemo.sap.customer360.model.BusinessPartner; +import com.tricentisdemo.sap.customer360.model.BusinessPartnerAddress; +import com.tricentisdemo.sap.customer360.model.BusinessPartnerRole; +import com.tricentisdemo.sap.customer360.model.Customer360View; +import com.tricentisdemo.sap.customer360.persistence.CustomerNote; +import com.tricentisdemo.sap.customer360.persistence.CustomerTag; +import com.tricentisdemo.sap.customer360.sap.CorrelationIdInterceptor; +import com.tricentisdemo.sap.customer360.sap.SapApiException; +import com.tricentisdemo.sap.customer360.sap.SapBusinessPartnerClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * Fan-out aggregator composing a 360° view from SAP + local Postgres. + * + *

Each inbound {@code GET /api/v1/customers/{id}/360} now produces: + *

+ *   1 inbound HTTP
+ *       │
+ *       ├─ 3 parallel HTTPS GETs to SAP OData
+ *       │     GET /A_BusinessPartner('{id}')
+ *       │     GET /A_BusinessPartner('{id}')/to_BusinessPartnerAddress
+ *       │     GET /A_BusinessPartner('{id}')/to_BusinessPartnerRole
+ *       │
+ *       ├─ 2 parallel Postgres SELECTs
+ *       │     SELECT … FROM customer_tag   WHERE customer_id = ?
+ *       │     SELECT … FROM customer_note  WHERE customer_id = ?
+ *       │
+ *       └─ 1 Postgres INSERT (audit)
+ *             INSERT INTO audit_event (…)
+ * 
+ * + *

That's the full story for Keploy: every call on the request path — + * HTTP and Postgres wire-protocol — is visible to eBPF on the host and + * gets captured into the replay mocks. Tosca can only assert on the + * rendered tile; Keploy sees all six backend conversations. + * + *

Partial-failure policy: the SAP partner fetch is mandatory. + * Everything else (addresses, roles, tags, notes) is optional — failure + * degrades the view rather than the request. + */ +@Service +public class Customer360AggregatorService { + + private static final Logger log = LoggerFactory.getLogger(Customer360AggregatorService.class); + + private final SapBusinessPartnerClient sapClient; + private final TagService tagService; + private final NoteService noteService; + private final AuditService auditService; + private final Executor fanoutExecutor; + + @Value("${customer360.aggregate-timeout-seconds:25}") + private int aggregateTimeoutSeconds; + + @Value("${sap.api.base-url}") + private String dataSourceHint; + + public Customer360AggregatorService(SapBusinessPartnerClient sapClient, + TagService tagService, + NoteService noteService, + AuditService auditService, + @Qualifier("sapCallExecutor") Executor fanoutExecutor) { + this.sapClient = sapClient; + this.tagService = tagService; + this.noteService = noteService; + this.auditService = auditService; + this.fanoutExecutor = fanoutExecutor; + } + + public Customer360View aggregate(String customerId) { + Instant start = Instant.now(); + String correlationId = MDC.get(CorrelationIdInterceptor.MDC_KEY); + log.info("Aggregating 360 view for customerId={} correlationId={}", customerId, correlationId); + + // ---- Mandatory: SAP partner -------------------------------------- + BusinessPartner partner = sapClient.fetchPartner(customerId); + + // ---- Parallel fan-out: 2 SAP + 2 Postgres ------------------------ + CompletableFuture> addressesF = async(correlationId, + () -> safely("addresses", () -> sapClient.fetchAddresses(customerId))); + + CompletableFuture> rolesF = async(correlationId, + () -> safely("roles", () -> sapClient.fetchRoles(customerId))); + + CompletableFuture> tagsF = async(correlationId, + () -> safely("tags", () -> tagService.list(customerId))); + + CompletableFuture> notesF = async(correlationId, + () -> safely("notes", () -> noteService.list(customerId))); + + List addresses; + List roles; + List tags; + List notes; + try { + CompletableFuture.allOf(addressesF, rolesF, tagsF, notesF) + .get(aggregateTimeoutSeconds, TimeUnit.SECONDS); + addresses = addressesF.get(); + roles = rolesF.get(); + tags = tagsF.get(); + notes = notesF.get(); + } catch (TimeoutException e) { + log.warn("Aggregation timeout for bp={} after {}s — returning partial view", + customerId, aggregateTimeoutSeconds); + addresses = addressesF.getNow(List.of()); + roles = rolesF.getNow(List.of()); + tags = tagsF.getNow(List.of()); + notes = notesF.getNow(List.of()); + } catch (InterruptedException | ExecutionException e) { + Thread.currentThread().interrupt(); + log.warn("Aggregation interrupted for bp={}: {}", customerId, e.getMessage()); + addresses = addressesF.getNow(List.of()); + roles = rolesF.getNow(List.of()); + tags = tagsF.getNow(List.of()); + notes = notesF.getNow(List.of()); + } + + int elapsed = (int) Duration.between(start, Instant.now()).toMillis(); + + // ---- Audit INSERT (synchronous — part of the recorded path) ------ + auditService.record(customerId, "customer.360", elapsed); + + Customer360View view = new Customer360View(); + view.setCustomerId(customerId); + view.setPartner(partner); + view.setAddresses(addresses); + view.setRoles(roles); + view.setTags(tags); + view.setNotes(notes); + view.setAggregatedAt(Instant.now()); + view.setCorrelationId(correlationId); + view.setDataSource(dataSourceHint); + view.setElapsedMs(elapsed); + + log.info("360 aggregated bp={} addresses={} roles={} tags={} notes={} took={}ms", + customerId, addresses.size(), roles.size(), tags.size(), notes.size(), elapsed); + + return view; + } + + // ---- helpers ----------------------------------------------------------- + + private CompletableFuture> async(String correlationId, java.util.function.Supplier> supplier) { + return CompletableFuture.supplyAsync(() -> { + if (correlationId != null) MDC.put(CorrelationIdInterceptor.MDC_KEY, correlationId); + try { + return supplier.get(); + } finally { + MDC.remove(CorrelationIdInterceptor.MDC_KEY); + } + }, fanoutExecutor); + } + + private static List safely(String what, java.util.function.Supplier> supplier) { + try { + return supplier.get(); + } catch (SapApiException sap) { + log.warn("{} failed (SAP): {}", what, sap.getMessage()); + return List.of(); + } catch (RuntimeException e) { + log.warn("{} failed ({}): {}", what, e.getClass().getSimpleName(), e.getMessage()); + return List.of(); + } + } +} diff --git a/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/service/CustomerService.java b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/service/CustomerService.java new file mode 100644 index 00000000..21f03aef --- /dev/null +++ b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/service/CustomerService.java @@ -0,0 +1,46 @@ +package com.tricentisdemo.sap.customer360.service; + +import com.tricentisdemo.sap.customer360.model.BusinessPartner; +import com.tricentisdemo.sap.customer360.model.CustomerSummary; +import com.tricentisdemo.sap.customer360.sap.SapBusinessPartnerClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * Business-level facade for simple (single-endpoint) customer lookups. + * + *

The 360 aggregator lives in its own service because it has very + * different operational characteristics (parallelism, fan-out latency, + * partial-failure policy). + */ +@Service +public class CustomerService { + + private static final Logger log = LoggerFactory.getLogger(CustomerService.class); + + private final SapBusinessPartnerClient sapClient; + + public CustomerService(SapBusinessPartnerClient sapClient) { + this.sapClient = sapClient; + } + + public BusinessPartner getById(String id) { + log.debug("getById id={}", id); + return sapClient.fetchPartner(id); + } + + public List listCustomers(int top, int skip) { + log.debug("listCustomers top={} skip={}", top, skip); + return sapClient.listPartners(top, skip).stream() + .map(CustomerSummary::from) + .toList(); + } + + public long totalCount() { + log.debug("totalCount"); + return sapClient.fetchTotalCount(); + } +} diff --git a/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/service/NoteService.java b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/service/NoteService.java new file mode 100644 index 00000000..8782f786 --- /dev/null +++ b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/service/NoteService.java @@ -0,0 +1,39 @@ +package com.tricentisdemo.sap.customer360.service; + +import com.tricentisdemo.sap.customer360.persistence.CustomerNote; +import com.tricentisdemo.sap.customer360.repository.CustomerNoteRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +public class NoteService { + + private final CustomerNoteRepository repo; + + public NoteService(CustomerNoteRepository repo) { + this.repo = repo; + } + + @Transactional(readOnly = true) + public List list(String customerId) { + return repo.findAllByCustomerIdOrderByCreatedAtDesc(customerId); + } + + @Transactional + public CustomerNote add(String customerId, String body, String author) { + if (body == null || body.isBlank()) { + throw new IllegalArgumentException("note body must not be blank"); + } + if (body.length() > 2000) { + throw new IllegalArgumentException("note body must be ≤ 2000 chars"); + } + return repo.save(new CustomerNote(customerId, body.trim(), author)); + } + + @Transactional(readOnly = true) + public long count(String customerId) { + return repo.countByCustomerId(customerId); + } +} diff --git a/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/service/TagService.java b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/service/TagService.java new file mode 100644 index 00000000..4bdf43e9 --- /dev/null +++ b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/service/TagService.java @@ -0,0 +1,59 @@ +package com.tricentisdemo.sap.customer360.service; + +import com.tricentisdemo.sap.customer360.persistence.CustomerTag; +import com.tricentisdemo.sap.customer360.repository.CustomerTagRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +public class TagService { + + private static final Logger log = LoggerFactory.getLogger(TagService.class); + + private final CustomerTagRepository repo; + + public TagService(CustomerTagRepository repo) { + this.repo = repo; + } + + @Transactional(readOnly = true) + public List list(String customerId) { + return repo.findAllByCustomerIdOrderByCreatedAtDesc(customerId); + } + + @Transactional + public CustomerTag add(String customerId, String tag, String createdBy) { + if (tag == null || tag.isBlank()) { + throw new IllegalArgumentException("tag must not be blank"); + } + String normalised = tag.trim().toLowerCase(); + if (repo.existsByCustomerIdAndTag(customerId, normalised)) { + // Idempotent — return the existing row instead of 409'ing. + return repo.findAllByCustomerIdOrderByCreatedAtDesc(customerId).stream() + .filter(t -> t.getTag().equals(normalised)) + .findFirst() + .orElseThrow(); + } + try { + return repo.save(new CustomerTag(customerId, normalised, createdBy)); + } catch (DataIntegrityViolationException race) { + // Unique constraint lost a race to another thread — treat as + // already-present. + log.debug("tag add race on ({},{}); fetching existing", customerId, normalised); + return repo.findAllByCustomerIdOrderByCreatedAtDesc(customerId).stream() + .filter(t -> t.getTag().equals(normalised)) + .findFirst() + .orElseThrow(); + } + } + + @Transactional + public boolean remove(String customerId, String tag) { + return repo.deleteByCustomerIdAndTag(customerId, tag.trim().toLowerCase()) > 0; + } +} diff --git a/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/web/AuditController.java b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/web/AuditController.java new file mode 100644 index 00000000..a46021bc --- /dev/null +++ b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/web/AuditController.java @@ -0,0 +1,41 @@ +package com.tricentisdemo.sap.customer360.web; + +import com.tricentisdemo.sap.customer360.persistence.AuditEvent; +import com.tricentisdemo.sap.customer360.service.AuditService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/customers") +@Tag(name = "Audit", description = "Service-level access audit (Postgres read)") +public class AuditController { + + private static final Logger log = LoggerFactory.getLogger(AuditController.class); + + private final AuditService audit; + + public AuditController(AuditService audit) { + this.audit = audit; + } + + @Operation(summary = "Most recent 50 audit events across all customers", + description = "Postgres-only — no SAP call. Useful for compliance/ops dashboards.") + @GetMapping("/recent-views") + public ResponseEntity> recent() { + log.info("GET /customers/recent-views"); + List events = audit.recent(); + return ResponseEntity.ok(Map.of( + "items", events, + "count", events.size() + )); + } +} diff --git a/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/web/CustomerController.java b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/web/CustomerController.java new file mode 100644 index 00000000..a2bce19e --- /dev/null +++ b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/web/CustomerController.java @@ -0,0 +1,100 @@ +package com.tricentisdemo.sap.customer360.web; + +import com.tricentisdemo.sap.customer360.model.BusinessPartner; +import com.tricentisdemo.sap.customer360.model.Customer360View; +import com.tricentisdemo.sap.customer360.model.CustomerSummary; +import com.tricentisdemo.sap.customer360.service.Customer360AggregatorService; +import com.tricentisdemo.sap.customer360.service.CustomerService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +/** + * Inbound REST surface. + * + *

Four endpoints mirror the typical consumption pattern of a Customer 360 + * service from downstream CRM / portal / analytics: + * + * + * + * + * + * + *
EndpointUse caseSAP calls
GET /api/v1/customersPaged list for UI grids1
GET /api/v1/customers/{id}Single-entity lookup1
GET /api/v1/customers/{id}/360Aggregated 360 view3 parallel
GET /api/v1/customers/countKPI tiles / metrics1
+ */ +@RestController +@RequestMapping("/api/v1/customers") +@Validated +@Tag(name = "Customers", description = "SAP Business Partner aggregation endpoints") +public class CustomerController { + + private static final Logger log = LoggerFactory.getLogger(CustomerController.class); + + // SAP Business Partner id is alphanumeric, up to 10 chars in the sandbox. + private static final String ID_REGEX = "^[0-9A-Za-z]{1,10}$"; + + private final CustomerService customerService; + private final Customer360AggregatorService aggregatorService; + + public CustomerController(CustomerService customerService, + Customer360AggregatorService aggregatorService) { + this.customerService = customerService; + this.aggregatorService = aggregatorService; + } + + @Operation(summary = "List customers (paged)") + @GetMapping + public ResponseEntity> list( + @RequestParam(defaultValue = "10") @Min(1) @Max(100) int top, + @RequestParam(defaultValue = "0") @Min(0) int skip + ) { + log.info("GET /customers top={} skip={}", top, skip); + List items = customerService.listCustomers(top, skip); + return ResponseEntity.ok(Map.of( + "items", items, + "page", Map.of("top", top, "skip", skip, "size", items.size()) + )); + } + + @Operation(summary = "Get total customer count (KPI tile)") + @GetMapping("/count") + public ResponseEntity> count() { + log.info("GET /customers/count"); + long total = customerService.totalCount(); + return ResponseEntity.ok(Map.of("total", total)); + } + + @Operation(summary = "Get single customer master data") + @GetMapping("/{id}") + public ResponseEntity getById( + @PathVariable @NotBlank @Pattern(regexp = ID_REGEX) String id + ) { + log.info("GET /customers/{}", id); + return ResponseEntity.ok(customerService.getById(id)); + } + + @Operation(summary = "Get aggregated Customer 360 view", + description = "Fan-out aggregator — triggers 3 parallel SAP OData calls per request.") + @GetMapping("/{id}/360") + public ResponseEntity get360( + @PathVariable @NotBlank @Pattern(regexp = ID_REGEX) String id + ) { + log.info("GET /customers/{}/360", id); + return ResponseEntity.ok(aggregatorService.aggregate(id)); + } +} diff --git a/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/web/GlobalExceptionHandler.java b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/web/GlobalExceptionHandler.java new file mode 100644 index 00000000..61c635a9 --- /dev/null +++ b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/web/GlobalExceptionHandler.java @@ -0,0 +1,135 @@ +package com.tricentisdemo.sap.customer360.web; + +import com.tricentisdemo.sap.customer360.model.ProblemResponse; +import com.tricentisdemo.sap.customer360.sap.CorrelationIdInterceptor; +import com.tricentisdemo.sap.customer360.sap.SapApiException; +import io.github.resilience4j.circuitbreaker.CallNotPermittedException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.ConstraintViolationException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; +import org.springframework.dao.DataAccessException; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * Converts uncaught exceptions into RFC 7807 problem responses. + * + *

Every error path populates: + *

    + *
  • the {@code X-Correlation-ID} response header (for cross-system tracing)
  • + *
  • the {@code X-Upstream-Status} header when an SAP upstream was + * responsible for the failure (Keploy captures this on replay so + * downstream tests can assert on SAP-side status transitions)
  • + *
+ */ +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + @ExceptionHandler(SapApiException.class) + public ResponseEntity handleSap(SapApiException ex, HttpServletRequest req) { + HttpStatus status = mapSapStatus(ex.getUpstreamStatus()); + ProblemResponse body = baseProblem(status, "SAP upstream error", ex.getMessage(), req); + body.setUpstreamStatus(ex.getUpstreamStatus().value()); + body.setSapErrorCode(ex.getSapErrorCode()); + + log.warn("SAP error surfaced to caller: status={} upstream={} detail={}", + status.value(), ex.getUpstreamStatus().value(), ex.getMessage()); + + return ResponseEntity.status(status) + .headers(commonHeaders(ex.getUpstreamStatus().value())) + .body(body); + } + + @ExceptionHandler(CallNotPermittedException.class) + public ResponseEntity handleCircuitOpen(CallNotPermittedException ex, + HttpServletRequest req) { + log.warn("Circuit breaker open, rejecting call: {}", ex.getMessage()); + ProblemResponse body = baseProblem( + HttpStatus.SERVICE_UNAVAILABLE, + "SAP upstream temporarily unavailable", + "Circuit breaker is open; retry after a short delay.", + req); + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) + .headers(commonHeaders(null)) + .body(body); + } + + @ExceptionHandler({ + ConstraintViolationException.class, + MethodArgumentNotValidException.class, + IllegalArgumentException.class, + HttpMessageNotReadableException.class + }) + public ResponseEntity handleValidation(Exception ex, HttpServletRequest req) { + ProblemResponse body = baseProblem(HttpStatus.BAD_REQUEST, "Validation failed", + ex.getMessage(), req); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .headers(commonHeaders(null)) + .body(body); + } + + @ExceptionHandler(DataAccessException.class) + public ResponseEntity handleDb(DataAccessException ex, HttpServletRequest req) { + log.warn("database error surfaced to caller", ex); + ProblemResponse body = baseProblem(HttpStatus.SERVICE_UNAVAILABLE, + "Database error", "Local persistence layer is unavailable.", req); + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) + .headers(commonHeaders(null)) + .body(body); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleUnexpected(Exception ex, HttpServletRequest req) { + log.error("Unhandled exception", ex); + ProblemResponse body = baseProblem(HttpStatus.INTERNAL_SERVER_ERROR, + "Internal Server Error", ex.getMessage(), req); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .headers(commonHeaders(null)) + .body(body); + } + + // ----------------------------------------------------------------------- + + private static ProblemResponse baseProblem(HttpStatus status, String title, + String detail, HttpServletRequest req) { + ProblemResponse body = new ProblemResponse(); + body.setType("about:blank"); + body.setTitle(title); + body.setStatus(status.value()); + body.setDetail(detail); + body.setInstance(req.getRequestURI()); + body.setCorrelationId(MDC.get(CorrelationIdInterceptor.MDC_KEY)); + return body; + } + + private static HttpHeaders commonHeaders(Integer upstreamStatus) { + HttpHeaders h = new HttpHeaders(); + String cid = MDC.get(CorrelationIdInterceptor.MDC_KEY); + if (cid != null) { + h.set(CorrelationIdInterceptor.HEADER, cid); + } + if (upstreamStatus != null) { + h.set("X-Upstream-Status", String.valueOf(upstreamStatus)); + } + return h; + } + + private static HttpStatus mapSapStatus(HttpStatus upstream) { + // SAP 404 on a specific partner → 404 Not Found to our caller. + // SAP 401/403 → reflect as 502 (the caller didn't auth wrong, we did). + // SAP 4xx otherwise → 502 (bad gateway / upstream misconfig). + // SAP 5xx / timeouts → 502. + if (upstream == HttpStatus.NOT_FOUND) return HttpStatus.NOT_FOUND; + if (upstream == HttpStatus.TOO_MANY_REQUESTS) return HttpStatus.TOO_MANY_REQUESTS; + return HttpStatus.BAD_GATEWAY; + } +} diff --git a/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/web/NoteController.java b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/web/NoteController.java new file mode 100644 index 00000000..ea5e912a --- /dev/null +++ b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/web/NoteController.java @@ -0,0 +1,70 @@ +package com.tricentisdemo.sap.customer360.web; + +import com.tricentisdemo.sap.customer360.model.NoteRequest; +import com.tricentisdemo.sap.customer360.persistence.CustomerNote; +import com.tricentisdemo.sap.customer360.service.AuditService; +import com.tricentisdemo.sap.customer360.service.NoteService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/customers/{customerId}/notes") +@Validated +@Tag(name = "Notes", description = "Free-text notes captured locally in Postgres") +public class NoteController { + + private static final Logger log = LoggerFactory.getLogger(NoteController.class); + + private static final String ID_REGEX = "^[0-9A-Za-z]{1,10}$"; + + private final NoteService notes; + private final AuditService audit; + + public NoteController(NoteService notes, AuditService audit) { + this.notes = notes; + this.audit = audit; + } + + @Operation(summary = "List notes for a customer (Postgres read)") + @GetMapping + public ResponseEntity> list( + @PathVariable @NotBlank @Pattern(regexp = ID_REGEX) String customerId + ) { + log.info("GET notes for bp={}", customerId); + List result = notes.list(customerId); + audit.record(customerId, "notes.list", null); + return ResponseEntity.ok(Map.of( + "items", result, + "count", result.size() + )); + } + + @Operation(summary = "Add a note to a customer (Postgres insert)") + @PostMapping + public ResponseEntity add( + @PathVariable @NotBlank @Pattern(regexp = ID_REGEX) String customerId, + @Valid @RequestBody NoteRequest req + ) { + log.info("POST note len={} for bp={}", req.getBody().length(), customerId); + String author = req.getAuthor() == null || req.getAuthor().isBlank() ? "api" : req.getAuthor(); + CustomerNote saved = notes.add(customerId, req.getBody(), author); + audit.record(customerId, "notes.add", null); + return ResponseEntity.ok(saved); + } +} diff --git a/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/web/TagController.java b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/web/TagController.java new file mode 100644 index 00000000..d8bddc18 --- /dev/null +++ b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/web/TagController.java @@ -0,0 +1,80 @@ +package com.tricentisdemo.sap.customer360.web; + +import com.tricentisdemo.sap.customer360.model.TagRequest; +import com.tricentisdemo.sap.customer360.persistence.CustomerTag; +import com.tricentisdemo.sap.customer360.service.AuditService; +import com.tricentisdemo.sap.customer360.service.TagService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/customers/{customerId}/tags") +@Validated +@Tag(name = "Tags", description = "Local labels attached to a BP — stored in Postgres, not SAP") +public class TagController { + + private static final Logger log = LoggerFactory.getLogger(TagController.class); + + private static final String ID_REGEX = "^[0-9A-Za-z]{1,10}$"; + + private final TagService tags; + private final AuditService audit; + + public TagController(TagService tags, AuditService audit) { + this.tags = tags; + this.audit = audit; + } + + @Operation(summary = "List tags on a customer (Postgres read)") + @GetMapping + public ResponseEntity> list( + @PathVariable @NotBlank @Pattern(regexp = ID_REGEX) String customerId + ) { + log.info("GET tags for bp={}", customerId); + List result = tags.list(customerId); + audit.record(customerId, "tags.list", null); + return ResponseEntity.ok(result); + } + + @Operation(summary = "Add a tag to a customer (Postgres insert)") + @PostMapping + public ResponseEntity add( + @PathVariable @NotBlank @Pattern(regexp = ID_REGEX) String customerId, + @Valid @RequestBody TagRequest req + ) { + log.info("POST tag={} for bp={}", req.getTag(), customerId); + String by = req.getCreatedBy() == null || req.getCreatedBy().isBlank() ? "api" : req.getCreatedBy(); + CustomerTag saved = tags.add(customerId, req.getTag(), by); + audit.record(customerId, "tags.add", null); + return ResponseEntity.ok(saved); + } + + @Operation(summary = "Remove a tag (Postgres delete)") + @DeleteMapping("/{tag}") + public ResponseEntity> remove( + @PathVariable @NotBlank @Pattern(regexp = ID_REGEX) String customerId, + @PathVariable @NotBlank String tag + ) { + log.info("DELETE tag={} for bp={}", tag, customerId); + boolean deleted = tags.remove(customerId, tag); + audit.record(customerId, "tags.delete", null); + return ResponseEntity.ok(Map.of("deleted", deleted, "tag", tag.toLowerCase())); + } +} diff --git a/sap-demo-java/src/main/resources/application.yml b/sap-demo-java/src/main/resources/application.yml new file mode 100644 index 00000000..b3db665e --- /dev/null +++ b/sap-demo-java/src/main/resources/application.yml @@ -0,0 +1,177 @@ +spring: + application: + name: customer360 + jackson: + default-property-inclusion: non_null + serialization: + write-dates-as-timestamps: false + + # --- Postgres / JPA ---------------------------------------------------- + # The app persists tags, notes, and an audit log locally. In a RISE/BTP + # landscape this would be a hyperscaler-hosted Postgres or SAP HANA Cloud; + # for the demo we use stock Postgres, which Keploy's OSS core can record + # and replay at the wire-protocol level. + datasource: + url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/customer360} + username: ${SPRING_DATASOURCE_USERNAME:customer360} + password: ${SPRING_DATASOURCE_PASSWORD:customer360} + hikari: + maximum-pool-size: 10 + minimum-idle: 2 + connection-timeout: 10000 + pool-name: customer360-hikari + jpa: + hibernate: + ddl-auto: validate + open-in-view: false + properties: + hibernate: + jdbc: + time_zone: UTC + format_sql: false + flyway: + enabled: true + baseline-on-migrate: true + locations: classpath:db/migration + + # --- Cache ----------------------------------------------------------- + # Caffeine-backed caches for SAP master data. Typical BTP-extension + # caching TTL is 30s-5min; we use 60s for demo visibility. Keploy + # records only the first-uncached call per (method, argument) — exactly + # what happens in production traffic. + cache: + type: caffeine + cache-names: + - sap.partner + - sap.partners-page + - sap.addresses + - sap.roles + - sap.count + caffeine: + spec: maximumSize=5000,expireAfterWrite=60s + +server: + port: ${SERVER_PORT:8080} + # Graceful shutdown gives in-flight requests a chance to finish during + # rolling deploys on Kubernetes. + shutdown: graceful + forward-headers-strategy: native + compression: + enabled: false + http2: + enabled: true + tomcat: + mbeanregistry: + enabled: true + +# ---------------------------------------------------------------------------- +# SAP upstream configuration +# ---------------------------------------------------------------------------- +sap: + api: + # Defaults to the public SAP Business Accelerator Hub sandbox; override + # for a real tenant by setting SAP_API_BASE_URL (and providing a bearer + # token or API key for that tenant). + base-url: ${SAP_API_BASE_URL:https://sandbox.api.sap.com/s4hanacloud} + key: ${SAP_API_KEY:} + bearer-token: ${SAP_BEARER_TOKEN:} + connect-timeout-seconds: 10 + read-timeout-seconds: 30 + default-top: 10 + +customer360: + aggregate-timeout-seconds: 25 + +# ---------------------------------------------------------------------------- +# Resilience4j: retry + circuit breaker for SAP upstream calls +# ---------------------------------------------------------------------------- +resilience4j: + retry: + instances: + sapApi: + max-attempts: 3 + wait-duration: 500ms + enable-exponential-backoff: true + exponential-backoff-multiplier: 2 + retry-exceptions: + - org.springframework.web.client.ResourceAccessException + - java.net.SocketTimeoutException + ignore-exceptions: + - com.tricentisdemo.sap.customer360.sap.SapApiException + circuitbreaker: + instances: + sapApi: + register-health-indicator: true + sliding-window-type: COUNT_BASED + sliding-window-size: 20 + minimum-number-of-calls: 10 + failure-rate-threshold: 60 + slow-call-rate-threshold: 70 + slow-call-duration-threshold: 20s + permitted-number-of-calls-in-half-open-state: 3 + wait-duration-in-open-state: 10s + automatic-transition-from-open-to-half-open-enabled: true + +# ---------------------------------------------------------------------------- +# Spring Boot Actuator — health, metrics, info +# /actuator/health is the Kubernetes liveness/readiness probe target. +# ---------------------------------------------------------------------------- +management: + endpoints: + web: + exposure: + include: health, info, metrics, prometheus + base-path: /actuator + endpoint: + health: + probes: + enabled: true + show-details: when_authorized + show-components: always + group: + liveness: + include: livenessState + readiness: + include: readinessState, circuitBreakers + health: + circuitbreakers: + enabled: true + db: + enabled: true + info: + env: + enabled: true + metrics: + tags: + application: ${spring.application.name} + distribution: + percentiles-histogram: + http.server.requests: true + percentiles: + http.server.requests: 0.5, 0.95, 0.99 +info: + app: + name: ${spring.application.name} + description: SAP Customer 360 aggregator — reference integration service + version: 1.0.0 + +# ---------------------------------------------------------------------------- +# OpenAPI / Swagger UI +# ---------------------------------------------------------------------------- +springdoc: + api-docs: + path: /v3/api-docs + swagger-ui: + path: /swagger-ui.html + operationsSorter: method + +# ---------------------------------------------------------------------------- +# Logging — structured pattern with correlation id so every line is traceable. +# ---------------------------------------------------------------------------- +logging: + level: + root: INFO + com.tricentisdemo.sap: INFO + org.springframework.web: INFO + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%X{correlationId:-}] %logger{40} - %msg%n" diff --git a/sap-demo-java/src/main/resources/db/migration/V1__init_schema.sql b/sap-demo-java/src/main/resources/db/migration/V1__init_schema.sql new file mode 100644 index 00000000..e0888527 --- /dev/null +++ b/sap-demo-java/src/main/resources/db/migration/V1__init_schema.sql @@ -0,0 +1,41 @@ +-- Customer 360 local schema. +-- +-- The app is a BTP-style extension: it enriches SAP Business Partner master +-- data with local-only concerns (tags, free-text notes, access audit log) +-- that never touch SAP. This separation is the "clean core" pattern — +-- local deltas live here; SAP stays canonical for master data. +-- +-- Tables: +-- customer_tag — user-assigned labels on a BP ("vip", "delinquent", etc.) +-- customer_note — free-text notes captured by CSRs during calls +-- audit_event — every read/write, for compliance + usage analytics + +CREATE TABLE customer_tag ( + id BIGSERIAL PRIMARY KEY, + customer_id VARCHAR(10) NOT NULL, + tag VARCHAR(64) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by VARCHAR(64) NOT NULL DEFAULT 'system', + CONSTRAINT uk_customer_tag UNIQUE (customer_id, tag) +); +CREATE INDEX idx_customer_tag_customer_id ON customer_tag(customer_id); + +CREATE TABLE customer_note ( + id BIGSERIAL PRIMARY KEY, + customer_id VARCHAR(10) NOT NULL, + body TEXT NOT NULL, + author VARCHAR(64) NOT NULL DEFAULT 'system', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_customer_note_customer_id ON customer_note(customer_id); + +CREATE TABLE audit_event ( + id BIGSERIAL PRIMARY KEY, + customer_id VARCHAR(10), + operation VARCHAR(64) NOT NULL, + correlation_id VARCHAR(128), + latency_ms INTEGER, + happened_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_audit_event_happened_at ON audit_event(happened_at DESC); +CREATE INDEX idx_audit_event_customer_id ON audit_event(customer_id); diff --git a/sap-demo-java/src/main/resources/logback-spring.xml b/sap-demo-java/src/main/resources/logback-spring.xml new file mode 100644 index 00000000..cef2645f --- /dev/null +++ b/sap-demo-java/src/main/resources/logback-spring.xml @@ -0,0 +1,38 @@ + + + + + + + + + + ${LOG_PATTERN} + UTF-8 + + + + + + + + {"ts":"%d{yyyy-MM-dd'T'HH:mm:ss.SSSXXX}","level":"%level","logger":"%logger{40}","correlationId":"%X{correlationId:-}","msg":"%replace(%msg){'\"','\\\"'}"}%n + + + + + + + + + + + + + + diff --git a/sap-demo-java/src/test/java/com/tricentisdemo/sap/customer360/Customer360ApplicationTests.java b/sap-demo-java/src/test/java/com/tricentisdemo/sap/customer360/Customer360ApplicationTests.java new file mode 100644 index 00000000..10ff2496 --- /dev/null +++ b/sap-demo-java/src/test/java/com/tricentisdemo/sap/customer360/Customer360ApplicationTests.java @@ -0,0 +1,26 @@ +package com.tricentisdemo.sap.customer360; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +/** + * Minimal context-load smoke test. Verifies the Spring wiring is correct — + * RestTemplate, interceptors, executor, Resilience4j decorators all bind. + * + *

The full integration test suite is intentionally out of scope for this + * reference service: the regression layer is provided by Keploy mocks + * recorded from real SAP traffic. That is the whole point of the demo. + */ +@SpringBootTest +@TestPropertySource(properties = { + "sap.api.base-url=https://example.invalid", + "sap.api.key=test-key" +}) +class Customer360ApplicationTests { + + @Test + void contextLoads() { + // if Spring wires everything, we're good + } +} From 679fc8b8ce52d74d15ca319cf6d6a260c421fc8f Mon Sep 17 00:00:00 2001 From: slayerjain Date: Wed, 22 Apr 2026 11:49:44 +0530 Subject: [PATCH 2/3] review: address Copilot review round 2 on sap-demo-java MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies 17 fixes from https://github.com/keploy/samples-java/pull/126 review: Alignment / correctness: - Dockerfile: amazoncorretto:25 -> 21 (match pom java.version=21) - docker-compose.yml, k8s/postgres.yaml: postgres:15-alpine -> 16-alpine (align with README/PR "Postgres 16") - kind-config.yaml: add extraPortMappings for 30080 so the NodePort Service is reachable at http://localhost:30080 (previously 80/443 only) - SapClientConfig: plumb readTimeoutSeconds through RestTemplateBuilder (HttpComponentsClientHttpRequestFactory.setReadTimeout was removed in Spring 6 / HttpClient5) + drop Javadoc reference to the never-implemented IdentityEncodingInterceptor Bugs / robustness: - TagController.remove: trim + lowercase before echoing "tag" in response, so the response matches the value used by the service - GlobalExceptionHandler.handleUnexpected: stop leaking ex.getMessage() to clients; log full exception server-side with correlationId + path, return a generic body that invites the caller to quote the correlationId - TagService.add: replace exists() + findAll().stream().filter().orElseThrow() with a single findByCustomerIdAndTag Optional lookup, eliminating the race window + the unsafe orElseThrow(); CustomerTagRepository gains the matching finder - Customer360AggregatorService: split the combined `catch (InterruptedException | ExecutionException)` so Thread.currentThread().interrupt() fires only for InterruptedException - deploy_kind.sh build_and_load: replace `[ src -nt target/...jar ]` (which only compares the src directory mtime) with a `find src pom.xml -newer` sweep so rebuilds actually trigger after any file change Test / housekeeping: - Customer360ApplicationTests: add @AutoConfigureTestDatabase(Replace.ANY) + H2 test dep so the context-load smoke test runs without a live Postgres; disable Flyway (migrations are Postgres-specific) and let Hibernate generate the schema from entities at test time. Verified: context loads in ~5.6s locally - application.yml: drop logging.pattern.console — logback-spring.xml owns the pattern (and overrides Boot's key when present); avoids drift - keploy.yml: typo "configration" -> "configuration" - README.md Quick start: drop absolute /home/shubham/... path, use repo-relative `cd sap-demo-java` Co-Authored-By: Claude Opus 4.7 (1M context) --- sap-demo-java/Dockerfile | 11 ++++---- sap-demo-java/README.md | 2 +- sap-demo-java/deploy_kind.sh | 13 ++++++++- sap-demo-java/docker-compose.yml | 2 +- sap-demo-java/k8s/postgres.yaml | 4 +-- sap-demo-java/keploy.yml | 2 +- sap-demo-java/kind-config.yaml | 5 ++++ sap-demo-java/pom.xml | 7 +++++ .../customer360/config/SapClientConfig.java | 22 +++++++-------- .../repository/CustomerTagRepository.java | 3 ++- .../service/Customer360AggregatorService.java | 11 +++++++- .../sap/customer360/service/TagService.java | 27 +++++++++---------- .../web/GlobalExceptionHandler.java | 10 +++++-- .../sap/customer360/web/TagController.java | 5 ++-- .../src/main/resources/application.yml | 7 ++--- .../Customer360ApplicationTests.java | 18 ++++++++++--- 16 files changed, 98 insertions(+), 51 deletions(-) diff --git a/sap-demo-java/Dockerfile b/sap-demo-java/Dockerfile index 60adec38..d22ec3a3 100644 --- a/sap-demo-java/Dockerfile +++ b/sap-demo-java/Dockerfile @@ -1,13 +1,14 @@ # syntax=docker/dockerfile:1.7 # -# Uses amazoncorretto:25 — Amazon Linux 2023-based, minimal, JDK-ready. -# The jar is built outside Docker (mvn package); this image just packages it -# and runs it. K8s securityContext enforces the non-root runtime UID (1001). +# Uses amazoncorretto:21 — Amazon Linux 2023-based, minimal, JDK-ready. +# Matches the Maven toolchain (pom.xml targets Java 21). The jar is built +# outside Docker (mvn package); this image just packages it and runs it. +# K8s securityContext enforces the non-root runtime UID (1001). # # Docker Hub pull rate limits are avoided by relying on the locally cached -# amazoncorretto:25 image. +# amazoncorretto:21 image. -FROM amazoncorretto:25 +FROM amazoncorretto:21 WORKDIR /app diff --git a/sap-demo-java/README.md b/sap-demo-java/README.md index e06cce00..09acfc90 100644 --- a/sap-demo-java/README.md +++ b/sap-demo-java/README.md @@ -81,7 +81,7 @@ Swagger UI inside the kind cluster: `http://localhost:30080/swagger-ui.html`. ## Quick start (kind cluster) ```bash -cd /home/shubham/tricentis/sap_testing/sap_demo_java +cd sap-demo-java # 1. One-time: drop your SAP API key into .env cp .env.example .env diff --git a/sap-demo-java/deploy_kind.sh b/sap-demo-java/deploy_kind.sh index 0509ae1a..112cf2da 100755 --- a/sap-demo-java/deploy_kind.sh +++ b/sap-demo-java/deploy_kind.sh @@ -194,7 +194,18 @@ build_and_load() { fail "cluster '${CLUSTER_NAME}' does not exist — run '$0 -c ${CLUSTER_NAME} cluster' first" exit 1 fi - if [ ! -f target/customer360.jar ] || [ src -nt target/customer360.jar ]; then + # Rebuild the jar if any source file under src/ or pom.xml is newer than + # the jar — `dir -nt file` only compares the directory mtime, which does + # NOT move when files inside the directory are edited, so a plain + # `[ src -nt target/customer360.jar ]` would happily reuse a stale jar + # after a code change. + needs_build=1 + if [ -f target/customer360.jar ]; then + if [ -z "$(find src pom.xml -type f -newer target/customer360.jar -print -quit 2>/dev/null)" ]; then + needs_build=0 + fi + fi + if [ "${needs_build}" -eq 1 ]; then say "building customer360.jar (mvn package)" mvn -q -B -DskipTests package else diff --git a/sap-demo-java/docker-compose.yml b/sap-demo-java/docker-compose.yml index 807ec95e..93faf890 100644 --- a/sap-demo-java/docker-compose.yml +++ b/sap-demo-java/docker-compose.yml @@ -5,7 +5,7 @@ # For the demo, prefer ./deploy_kind.sh (matches production topology). services: postgres: - image: postgres:15-alpine + image: postgres:16-alpine container_name: customer360-postgres environment: POSTGRES_DB: customer360 diff --git a/sap-demo-java/k8s/postgres.yaml b/sap-demo-java/k8s/postgres.yaml index 4a7276e6..ed6dddb9 100644 --- a/sap-demo-java/k8s/postgres.yaml +++ b/sap-demo-java/k8s/postgres.yaml @@ -1,4 +1,4 @@ -# Postgres 15 for the Customer 360 local store (tags, notes, audit). +# Postgres 16 for the Customer 360 local store (tags, notes, audit). # # Kept in a single file for demo-legibility: Secret + Service + Deployment. # emptyDir volume (not PVC) — fresh DB on every pod restart, which is fine @@ -68,7 +68,7 @@ spec: terminationGracePeriodSeconds: 15 containers: - name: postgres - image: postgres:15-alpine + image: postgres:16-alpine imagePullPolicy: IfNotPresent ports: - name: postgres diff --git a/sap-demo-java/keploy.yml b/sap-demo-java/keploy.yml index 4e96c152..38a6365d 100755 --- a/sap-demo-java/keploy.yml +++ b/sap-demo-java/keploy.yml @@ -119,4 +119,4 @@ serverPort: 0 mockDownload: registryIds: [] -# Visit [https://keploy.io/docs/running-keploy/configuration-file/] to learn about using keploy through configration file. +# Visit [https://keploy.io/docs/running-keploy/configuration-file/] to learn about using keploy through configuration file. diff --git a/sap-demo-java/kind-config.yaml b/sap-demo-java/kind-config.yaml index efb4fcab..de1ff262 100644 --- a/sap-demo-java/kind-config.yaml +++ b/sap-demo-java/kind-config.yaml @@ -14,4 +14,9 @@ nodes: protocol: TCP - containerPort: 443 hostPort: 443 + protocol: TCP + # The NodePort Service (k8s/service.yaml) binds 30080 on every node; this + # mapping makes http://localhost:30080 reach the single-node kind cluster. + - containerPort: 30080 + hostPort: 30080 protocol: TCP \ No newline at end of file diff --git a/sap-demo-java/pom.xml b/sap-demo-java/pom.xml index 5c4d3a70..729beb65 100644 --- a/sap-demo-java/pom.xml +++ b/sap-demo-java/pom.xml @@ -125,6 +125,13 @@ spring-boot-starter-test test + + + com.h2database + h2 + test + diff --git a/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/config/SapClientConfig.java b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/config/SapClientConfig.java index ada656ab..2559bf13 100644 --- a/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/config/SapClientConfig.java +++ b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/config/SapClientConfig.java @@ -12,7 +12,6 @@ import org.springframework.http.client.ClientHttpRequestExecution; import org.springframework.http.client.ClientHttpRequestInterceptor; import org.springframework.http.client.ClientHttpResponse; -import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import org.springframework.web.client.RestTemplate; @@ -25,15 +24,10 @@ * Configures the {@link RestTemplate} used for all outbound calls to the SAP * S/4HANA Business Partner OData service. * - *

Three interceptors are attached: + *

Two interceptors are attached: *

    *
  1. SapAuthInterceptor — stamps the required {@code APIKey} header * (sandbox) or {@code Authorization: Bearer} (production tenant).
  2. - *
  3. IdentityEncodingInterceptor — forces {@code Accept-Encoding: identity} - * so bodies are not gzip-compressed in transit. In production this is - * optional; here it keeps Keploy-captured YAML mocks human-readable, - * which matters for demo-grade visibility and for diff-reviewing - * contract changes in pull requests.
  4. *
  5. CorrelationIdInterceptor — propagates the inbound request's * correlation id into the outbound SAP call so traces chain across * the hop.
  6. @@ -77,13 +71,17 @@ public RestTemplate sapRestTemplate(RestTemplateBuilder builder) { + "Outbound SAP calls will fail with 401."); } - HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(); - factory.setConnectTimeout((int) Duration.ofSeconds(connectTimeoutSeconds).toMillis()); - factory.setConnectionRequestTimeout((int) Duration.ofSeconds(connectTimeoutSeconds).toMillis()); - + // Spring Boot auto-selects HttpClient5 when httpclient5 is on the + // classpath (see pom.xml). Setting connect+read timeouts via the + // RestTemplateBuilder plumbs them through to the underlying + // HttpClient5 RequestConfig (connect) and SocketConfig (soTimeout == + // read timeout), which is the only API surface that still exists in + // Spring 6 — HttpComponentsClientHttpRequestFactory.setReadTimeout + // was removed with the HttpClient5 migration. return builder .rootUri(baseUrl) - .requestFactory(() -> factory) + .setConnectTimeout(Duration.ofSeconds(connectTimeoutSeconds)) + .setReadTimeout(Duration.ofSeconds(readTimeoutSeconds)) .additionalInterceptors( new SapAuthInterceptor(apiKey, bearerToken), new CorrelationIdInterceptor() diff --git a/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/repository/CustomerTagRepository.java b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/repository/CustomerTagRepository.java index f0bad274..d47d4a4e 100644 --- a/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/repository/CustomerTagRepository.java +++ b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/repository/CustomerTagRepository.java @@ -9,13 +9,14 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Optional; @Repository public interface CustomerTagRepository extends JpaRepository { List findAllByCustomerIdOrderByCreatedAtDesc(String customerId); - boolean existsByCustomerIdAndTag(String customerId, String tag); + Optional findByCustomerIdAndTag(String customerId, String tag); @Modifying @Transactional diff --git a/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/service/Customer360AggregatorService.java b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/service/Customer360AggregatorService.java index b259bada..765d2b74 100644 --- a/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/service/Customer360AggregatorService.java +++ b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/service/Customer360AggregatorService.java @@ -122,13 +122,22 @@ public Customer360View aggregate(String customerId) { roles = rolesF.getNow(List.of()); tags = tagsF.getNow(List.of()); notes = notesF.getNow(List.of()); - } catch (InterruptedException | ExecutionException e) { + } catch (InterruptedException e) { + // Re-assert interrupt so callers higher up can observe it. Thread.currentThread().interrupt(); log.warn("Aggregation interrupted for bp={}: {}", customerId, e.getMessage()); addresses = addressesF.getNow(List.of()); roles = rolesF.getNow(List.of()); tags = tagsF.getNow(List.of()); notes = notesF.getNow(List.of()); + } catch (ExecutionException e) { + // Execution failed inside an async stage; the interrupt flag of + // the caller is unaffected, so do NOT call Thread.interrupt(). + log.warn("Aggregation failed for bp={}: {}", customerId, e.getMessage()); + addresses = addressesF.getNow(List.of()); + roles = rolesF.getNow(List.of()); + tags = tagsF.getNow(List.of()); + notes = notesF.getNow(List.of()); } int elapsed = (int) Duration.between(start, Instant.now()).toMillis(); diff --git a/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/service/TagService.java b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/service/TagService.java index 4bdf43e9..c988c5b7 100644 --- a/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/service/TagService.java +++ b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/service/TagService.java @@ -32,23 +32,20 @@ public CustomerTag add(String customerId, String tag, String createdBy) { throw new IllegalArgumentException("tag must not be blank"); } String normalised = tag.trim().toLowerCase(); - if (repo.existsByCustomerIdAndTag(customerId, normalised)) { - // Idempotent — return the existing row instead of 409'ing. - return repo.findAllByCustomerIdOrderByCreatedAtDesc(customerId).stream() - .filter(t -> t.getTag().equals(normalised)) - .findFirst() - .orElseThrow(); - } + // Single-lookup idempotency: if the row exists, return it; otherwise + // insert. We retry the lookup on DataIntegrityViolationException to + // cover the narrow window where a concurrent insert beats us. + return repo.findByCustomerIdAndTag(customerId, normalised) + .orElseGet(() -> insertOrFetchExisting(customerId, normalised, createdBy)); + } + + private CustomerTag insertOrFetchExisting(String customerId, String tag, String createdBy) { try { - return repo.save(new CustomerTag(customerId, normalised, createdBy)); + return repo.save(new CustomerTag(customerId, tag, createdBy)); } catch (DataIntegrityViolationException race) { - // Unique constraint lost a race to another thread — treat as - // already-present. - log.debug("tag add race on ({},{}); fetching existing", customerId, normalised); - return repo.findAllByCustomerIdOrderByCreatedAtDesc(customerId).stream() - .filter(t -> t.getTag().equals(normalised)) - .findFirst() - .orElseThrow(); + log.debug("tag add race on ({},{}); fetching existing", customerId, tag); + return repo.findByCustomerIdAndTag(customerId, tag) + .orElseThrow(() -> race); } } diff --git a/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/web/GlobalExceptionHandler.java b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/web/GlobalExceptionHandler.java index 61c635a9..bbdaa00c 100644 --- a/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/web/GlobalExceptionHandler.java +++ b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/web/GlobalExceptionHandler.java @@ -89,9 +89,15 @@ public ResponseEntity handleDb(DataAccessException ex, HttpServ @ExceptionHandler(Exception.class) public ResponseEntity handleUnexpected(Exception ex, HttpServletRequest req) { - log.error("Unhandled exception", ex); + String cid = MDC.get(CorrelationIdInterceptor.MDC_KEY); + // Full exception (message, stack, class) stays server-side only. Clients + // receive a generic message plus the correlation id, which they can + // quote back to operators when opening a ticket. + log.error("Unhandled exception [{}] path={} — check server logs", cid, req.getRequestURI(), ex); ProblemResponse body = baseProblem(HttpStatus.INTERNAL_SERVER_ERROR, - "Internal Server Error", ex.getMessage(), req); + "Internal Server Error", + "An unexpected error occurred. Quote the correlationId when contacting support.", + req); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .headers(commonHeaders(null)) .body(body); diff --git a/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/web/TagController.java b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/web/TagController.java index d8bddc18..a0c44120 100644 --- a/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/web/TagController.java +++ b/sap-demo-java/src/main/java/com/tricentisdemo/sap/customer360/web/TagController.java @@ -73,8 +73,9 @@ public ResponseEntity> remove( @PathVariable @NotBlank String tag ) { log.info("DELETE tag={} for bp={}", tag, customerId); - boolean deleted = tags.remove(customerId, tag); + String normalised = tag.trim().toLowerCase(); + boolean deleted = tags.remove(customerId, normalised); audit.record(customerId, "tags.delete", null); - return ResponseEntity.ok(Map.of("deleted", deleted, "tag", tag.toLowerCase())); + return ResponseEntity.ok(Map.of("deleted", deleted, "tag", normalised)); } } diff --git a/sap-demo-java/src/main/resources/application.yml b/sap-demo-java/src/main/resources/application.yml index b3db665e..5356a1fb 100644 --- a/sap-demo-java/src/main/resources/application.yml +++ b/sap-demo-java/src/main/resources/application.yml @@ -166,12 +166,13 @@ springdoc: operationsSorter: method # ---------------------------------------------------------------------------- -# Logging — structured pattern with correlation id so every line is traceable. +# Logging — levels only. The console/JSON patterns live in logback-spring.xml +# (which is the single source of truth; Boot's `logging.pattern.*` keys are +# ignored whenever logback-spring.xml is present, so duplicating them here +# invited drift). # ---------------------------------------------------------------------------- logging: level: root: INFO com.tricentisdemo.sap: INFO org.springframework.web: INFO - pattern: - console: "%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%X{correlationId:-}] %logger{40} - %msg%n" diff --git a/sap-demo-java/src/test/java/com/tricentisdemo/sap/customer360/Customer360ApplicationTests.java b/sap-demo-java/src/test/java/com/tricentisdemo/sap/customer360/Customer360ApplicationTests.java index 10ff2496..a45a0ace 100644 --- a/sap-demo-java/src/test/java/com/tricentisdemo/sap/customer360/Customer360ApplicationTests.java +++ b/sap-demo-java/src/test/java/com/tricentisdemo/sap/customer360/Customer360ApplicationTests.java @@ -1,6 +1,7 @@ package com.tricentisdemo.sap.customer360; import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.TestPropertySource; @@ -8,14 +9,23 @@ * Minimal context-load smoke test. Verifies the Spring wiring is correct — * RestTemplate, interceptors, executor, Resilience4j decorators all bind. * - *

    The full integration test suite is intentionally out of scope for this - * reference service: the regression layer is provided by Keploy mocks - * recorded from real SAP traffic. That is the whole point of the demo. + *

    {@link AutoConfigureTestDatabase} replaces the configured Postgres + * with an in-memory H2 at test time so this test can run in environments + * without a live DB. Flyway is disabled for the same reason (migrations + * are Postgres-specific); Hibernate creates the schema from entities. + * + *

    The full integration test suite is intentionally out of scope for + * this reference service: the regression layer is provided by Keploy + * mocks recorded from real SAP traffic. That is the whole point of the + * demo. */ @SpringBootTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) @TestPropertySource(properties = { "sap.api.base-url=https://example.invalid", - "sap.api.key=test-key" + "sap.api.key=test-key", + "spring.flyway.enabled=false", + "spring.jpa.hibernate.ddl-auto=create-drop" }) class Customer360ApplicationTests { From f2407c7c23f07769548f93e99109d77ac519aed4 Mon Sep 17 00:00:00 2001 From: slayerjain Date: Wed, 22 Apr 2026 11:50:32 +0530 Subject: [PATCH 3/3] revert: drop H2 + in-test DB override in smoke test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per product direction, the e2e/sample path MUST exercise real Postgres + Flyway so Keploy is forced to correctly capture the full wire-protocol footprint — including the Flyway bootstrap/migration queries. Swapping the datasource for H2 at test time would hide exactly the behaviour the sample is meant to regress. Restores the Customer360ApplicationTests to its pre-review shape and drops the H2 test dependency. The test assumes a live Postgres on localhost:5432 (docker-compose.yml brings it up); added a Javadoc note so future readers don't re-H2 it. Co-Authored-By: Claude Opus 4.7 (1M context) --- sap-demo-java/pom.xml | 7 ------- .../Customer360ApplicationTests.java | 20 +++++++++---------- 2 files changed, 9 insertions(+), 18 deletions(-) diff --git a/sap-demo-java/pom.xml b/sap-demo-java/pom.xml index 729beb65..5c4d3a70 100644 --- a/sap-demo-java/pom.xml +++ b/sap-demo-java/pom.xml @@ -125,13 +125,6 @@ spring-boot-starter-test test - - - com.h2database - h2 - test - diff --git a/sap-demo-java/src/test/java/com/tricentisdemo/sap/customer360/Customer360ApplicationTests.java b/sap-demo-java/src/test/java/com/tricentisdemo/sap/customer360/Customer360ApplicationTests.java index a45a0ace..89ab53f5 100644 --- a/sap-demo-java/src/test/java/com/tricentisdemo/sap/customer360/Customer360ApplicationTests.java +++ b/sap-demo-java/src/test/java/com/tricentisdemo/sap/customer360/Customer360ApplicationTests.java @@ -1,7 +1,6 @@ package com.tricentisdemo.sap.customer360; import org.junit.jupiter.api.Test; -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.TestPropertySource; @@ -9,23 +8,22 @@ * Minimal context-load smoke test. Verifies the Spring wiring is correct — * RestTemplate, interceptors, executor, Resilience4j decorators all bind. * - *

    {@link AutoConfigureTestDatabase} replaces the configured Postgres - * with an in-memory H2 at test time so this test can run in environments - * without a live DB. Flyway is disabled for the same reason (migrations - * are Postgres-specific); Hibernate creates the schema from entities. + *

    This test expects a live Postgres instance on localhost:5432 + * (see docker-compose.yml). That is deliberate: the sample exists to + * validate that Keploy correctly captures real Postgres traffic — + * including the Flyway bootstrap queries — so swapping the datasource + * for H2 would hide exactly the wire-protocol behaviour we regress. + * Bring the compose stack up (`docker compose up -d postgres`) before + * running tests, or run via the CI pipeline where Postgres is provisioned. * *

    The full integration test suite is intentionally out of scope for * this reference service: the regression layer is provided by Keploy - * mocks recorded from real SAP traffic. That is the whole point of the - * demo. + * mocks recorded from real SAP traffic. */ @SpringBootTest -@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) @TestPropertySource(properties = { "sap.api.base-url=https://example.invalid", - "sap.api.key=test-key", - "spring.flyway.enabled=false", - "spring.jpa.hibernate.ddl-auto=create-drop" + "sap.api.key=test-key" }) class Customer360ApplicationTests {