From b4eed4396092d3f149fbd0e21856a6c3b2276414 Mon Sep 17 00:00:00 2001 From: grego952 Date: Wed, 13 May 2026 10:23:04 +0200 Subject: [PATCH 1/6] Add movies-rest --- movies-rest | 1 + 1 file changed, 1 insertion(+) create mode 160000 movies-rest diff --git a/movies-rest b/movies-rest new file mode 160000 index 00000000..5b405d06 --- /dev/null +++ b/movies-rest @@ -0,0 +1 @@ +Subproject commit 5b405d06af809020d4d0402edf8717322196e0a5 From c3c5ed899e57a84bbc5849185b07e0bc544d1162 Mon Sep 17 00:00:00 2001 From: grego952 Date: Wed, 13 May 2026 10:25:38 +0200 Subject: [PATCH 2/6] Add movies-rest --- movies-rest | 1 - movies-rest/.env | 2 + movies-rest/README.md | 119 ++++++++++++++++++ movies-rest/pom.xml | 62 +++++++++ .../java/com/example/movies/Application.java | 17 +++ .../main/java/com/example/movies/Movie.java | 20 +++ .../com/example/movies/MovieController.java | 101 +++++++++++++++ .../com/example/movies/ObjectStoreConfig.java | 49 ++++++++ .../src/main/resources/application.properties | 1 + 9 files changed, 371 insertions(+), 1 deletion(-) delete mode 160000 movies-rest create mode 100644 movies-rest/.env create mode 100644 movies-rest/README.md create mode 100644 movies-rest/pom.xml create mode 100644 movies-rest/src/main/java/com/example/movies/Application.java create mode 100644 movies-rest/src/main/java/com/example/movies/Movie.java create mode 100644 movies-rest/src/main/java/com/example/movies/MovieController.java create mode 100644 movies-rest/src/main/java/com/example/movies/ObjectStoreConfig.java create mode 100644 movies-rest/src/main/resources/application.properties diff --git a/movies-rest b/movies-rest deleted file mode 160000 index 5b405d06..00000000 --- a/movies-rest +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 5b405d06af809020d4d0402edf8717322196e0a5 diff --git a/movies-rest/.env b/movies-rest/.env new file mode 100644 index 00000000..c0d677a4 --- /dev/null +++ b/movies-rest/.env @@ -0,0 +1,2 @@ +BPL_JVM_THREAD_COUNT=20 +JAVA_TOOL_OPTIONS=-XX:ReservedCodeCacheSize=40M -XX:MaxMetaspaceSize=80M -Xss512k \ No newline at end of file diff --git a/movies-rest/README.md b/movies-rest/README.md new file mode 100644 index 00000000..a73010c9 --- /dev/null +++ b/movies-rest/README.md @@ -0,0 +1,119 @@ +# Movies REST API — Java / SAP BTP Kyma + +A Spring Boot REST API that keeps movie records as JSON objects in an S3-compatible **SAP Object Store** service. The app is deployed to **SAP BTP, Kyma runtime** using the `kyma-project/setup-kyma-cli/app-push` GitHub Action. + +## Architecture + +``` +GitHub Actions (CI/CD) + │ + ▼ +Kyma Runtime (Kubernetes) + ├── movies-rest Pod (Spring Boot, port 8080) + │ └── Istio sidecar (mTLS + ingress) + └── SAP Service Operator + └── ObjectStore ServiceInstance → S3 bucket +``` + +Each movie is stored as a JSON file at `movies/.json` inside the bound S3 bucket. No relational database is required. + +## Tech Stack + +| Layer | Technology | +|---|---| +| Language | Java 21 | +| Framework | Spring Boot 3.3 | +| API docs | springdoc-openapi / Swagger UI | +| Storage | AWS SDK v2 → SAP Object Store (S3-compatible) | +| Service binding | `java-sap-service-operator` (SAP Cloud Service Binding) | +| Runtime | SAP BTP Kyma (Kubernetes + Istio) | +| CI/CD | GitHub Actions + `kyma-project/setup-kyma-cli` | + +## API Endpoints + +| Method | Path | Description | +|---|---|---| +| `GET` | `/movies` | List all movies | +| `GET` | `/movies/{id}` | Get a movie by ID | +| `POST` | `/movies` | Create a new movie (ID auto-generated) | +| `PUT` | `/movies/{id}` | Update an existing movie | +| `DELETE` | `/movies/{id}` | Delete a movie | + +Interactive documentation is available at `/swagger-ui.html` after deployment. + +### Movie resource + +```json +{ + "id": "1714900000000", + "title": "Blade Runner", + "year": 1982, + "director": "Ridley Scott", + "rating": 8.1 +} +``` + +## Prerequisites + +- SAP BTP Kyma cluster +- GitHub repository secrets: + - `SERVER` — Kyma API server URL + - `CA_CRT` — Kyma cluster CA certificate +- OIDC client configured with audience `my-client-id-for-gh-action` + +## SAP Object Store Setup + +Apply the Kubernetes manifests to provision the Object Store service and bind it to the app namespace: + +```bash +kubectl apply -f movies-rest/service-instance.yaml +kubectl apply -f movies-rest/service-binding.yaml +``` + +These create: +- `ServiceInstance` `object-store-instance` — provisions an S3-compatible bucket via the SAP Service Operator +- `ServiceBinding` `object-store-binding` — injects credentials as a Kubernetes secret that the app reads at startup + +## Deployment + +Push to the `main` branch. The GitHub Actions workflow will: + +1. Install Kyma CLI +2. Obtain a kubeconfig using OIDC +3. Build and push the container image +4. Deploy the app to the `movie-rest` namespace with: + - Istio sidecar injection enabled + - Public ingress (`expose: true`) + - The `object-store-binding` secret mounted as a service binding + - JVM tuning from `.env` + +The workflow appends `/swagger-ui.html` to the output URL so you go directly on the API docs. + +## Local Development + +The app requires an Object Store service binding to start. For local testing, provide the binding via environment variables or a local `VCAP_SERVICES` / secrets file supported by the SAP Service Binding library. + +```bash +cd movies-rest +mvn spring-boot:run +``` + +The server starts on port `8080`. + +## Project Structure + +``` +movies-rest/ +├── src/main/java/com/example/movies/ +│ ├── Application.java # Spring Boot entry point +│ ├── Movie.java # Record: id, title, year, director, rating +│ ├── MovieController.java # REST controller — CRUD over S3 +│ └── ObjectStoreConfig.java # Reads SAP service binding, creates S3Client +├── src/main/resources/ +│ └── application.properties # server.port=8080 +├── .github/workflows/deploy.yaml # CI/CD pipeline +├── service-instance.yaml # SAP BTP ServiceInstance manifest +├── service-binding.yaml # SAP BTP ServiceBinding manifest +├── .env # JVM tuning flags for Buildpacks +└── pom.xml +``` diff --git a/movies-rest/pom.xml b/movies-rest/pom.xml new file mode 100644 index 00000000..74d16a7b --- /dev/null +++ b/movies-rest/pom.xml @@ -0,0 +1,62 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.0 + + + com.example + movies + 1.0.0 + + + 21 + + + + + + com.sap.cloud.environment.servicebinding + java-bom + 0.10.5 + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.5.0 + + + com.sap.cloud.environment.servicebinding + java-sap-service-operator + + + software.amazon.awssdk + s3 + 2.25.0 + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + \ No newline at end of file diff --git a/movies-rest/src/main/java/com/example/movies/Application.java b/movies-rest/src/main/java/com/example/movies/Application.java new file mode 100644 index 00000000..8a9e2102 --- /dev/null +++ b/movies-rest/src/main/java/com/example/movies/Application.java @@ -0,0 +1,17 @@ +package com.example.movies; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.info.Info; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +@OpenAPIDefinition(info = @Info( + title = "Movies API", + version = "1.0.0", + description = "CRUD REST service for movies, backed by SAP BTP Object Store")) +public class Application { + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} \ No newline at end of file diff --git a/movies-rest/src/main/java/com/example/movies/Movie.java b/movies-rest/src/main/java/com/example/movies/Movie.java new file mode 100644 index 00000000..94c9270f --- /dev/null +++ b/movies-rest/src/main/java/com/example/movies/Movie.java @@ -0,0 +1,20 @@ +package com.example.movies; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "Movie resource") +public record Movie( + @Schema(description = "Auto-generated ID", example = "1714900000000", accessMode = Schema.AccessMode.READ_ONLY) + String id, + @Schema(description = "Movie title", example = "Blade Runner") + String title, + @Schema(description = "Release year", example = "1982") + int year, + @Schema(description = "Director name", example = "Ridley Scott") + String director, + @Schema(description = "Rating out of 10", example = "8.1") + Double rating) { + public Movie withId(String newId) { + return new Movie(newId, title, year, director, rating); + } +} \ No newline at end of file diff --git a/movies-rest/src/main/java/com/example/movies/MovieController.java b/movies-rest/src/main/java/com/example/movies/MovieController.java new file mode 100644 index 00000000..3c0fb50d --- /dev/null +++ b/movies-rest/src/main/java/com/example/movies/MovieController.java @@ -0,0 +1,101 @@ +package com.example.movies; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.*; + +import java.io.IOException; +import java.util.List; + +@RestController +@RequestMapping("/movies") +@Tag(name = "Movies", description = "CRUD operations for movie resources") +public class MovieController { + + private final S3Client s3; + private final String bucket; + private final ObjectMapper mapper = new ObjectMapper(); + + public MovieController(S3Client s3, String bucketName) { + this.s3 = s3; + this.bucket = bucketName; + } + + @GetMapping + @Operation(summary = "List all movies") + public List list() throws IOException { + ListObjectsV2Request request = ListObjectsV2Request.builder() + .bucket(bucket) + .prefix("movies/") + .build(); + ListObjectsV2Response response = s3.listObjectsV2(request); + return response.contents().stream() + .map(obj -> getMovie(obj.key())) + .toList(); + } + + @GetMapping("/{id}") + @Operation(summary = "Get a movie by ID") + public Movie get(@PathVariable String id) { + return getMovie("movies/" + id + ".json"); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Operation(summary = "Create a new movie") + public Movie create(@org.springframework.web.bind.annotation.RequestBody Movie movie) throws Exception { + Movie saved = movie.withId(String.valueOf(System.currentTimeMillis())); + putMovie(saved); + return saved; + } + + @PutMapping("/{id}") + @Operation(summary = "Update an existing movie") + public Movie update(@PathVariable String id, @org.springframework.web.bind.annotation.RequestBody Movie movie) throws Exception { + Movie saved = movie.withId(id); + putMovie(saved); + return saved; + } + + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation(summary = "Delete a movie") + public void delete(@PathVariable String id) { + DeleteObjectRequest request = DeleteObjectRequest.builder() + .bucket(bucket) + .key("movies/" + id + ".json") + .build(); + s3.deleteObject(request); + } + + private void putMovie(Movie movie) throws Exception { + byte[] json = mapper.writeValueAsBytes(movie); + PutObjectRequest request = PutObjectRequest.builder() + .bucket(bucket) + .key("movies/" + movie.id() + ".json") + .contentType("application/json") + .build(); + s3.putObject(request, software.amazon.awssdk.core.sync.RequestBody.fromBytes(json)); + } + + private Movie getMovie(String key) { + try { + GetObjectRequest request = GetObjectRequest.builder() + .bucket(bucket) + .key(key) + .build(); + byte[] data = s3.getObject(request).readAllBytes(); + return mapper.readValue(data, Movie.class); + } catch (NoSuchKeyException e) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Movie not found"); + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} \ No newline at end of file diff --git a/movies-rest/src/main/java/com/example/movies/ObjectStoreConfig.java b/movies-rest/src/main/java/com/example/movies/ObjectStoreConfig.java new file mode 100644 index 00000000..e932978e --- /dev/null +++ b/movies-rest/src/main/java/com/example/movies/ObjectStoreConfig.java @@ -0,0 +1,49 @@ +package com.example.movies; + +import com.sap.cloud.environment.servicebinding.api.DefaultServiceBindingAccessor; +import com.sap.cloud.environment.servicebinding.api.ServiceBinding; +import com.sap.cloud.environment.servicebinding.api.ServiceBindingAccessor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; + +import java.net.URI; +import java.util.Map; + +@Configuration +public class ObjectStoreConfig { + + @Bean + public S3Client s3Client() { + ServiceBindingAccessor accessor = DefaultServiceBindingAccessor.getInstance(); + + ServiceBinding binding = accessor.getServiceBindings().stream() + .filter(b -> "objectstore".equals(b.getServiceName().orElse(null))) + .findFirst() + .orElseThrow(() -> new IllegalStateException("No matching Object Store binding found")); + + Map creds = binding.getCredentials(); + + return S3Client.builder() + .region(Region.of((String) creds.get("region"))) + .endpointOverride(URI.create("https://" + creds.get("host"))) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create( + (String) creds.get("access_key_id"), + (String) creds.get("secret_access_key")))) + .build(); + } + + @Bean + public String bucketName() { + ServiceBindingAccessor accessor = DefaultServiceBindingAccessor.getInstance(); + ServiceBinding binding = accessor.getServiceBindings().stream() + .filter(b -> "objectstore".equals(b.getServiceName().orElse(null))) + .findFirst() + .orElseThrow(); + return (String) binding.getCredentials().get("bucket"); + } +} \ No newline at end of file diff --git a/movies-rest/src/main/resources/application.properties b/movies-rest/src/main/resources/application.properties new file mode 100644 index 00000000..a3ac65ce --- /dev/null +++ b/movies-rest/src/main/resources/application.properties @@ -0,0 +1 @@ +server.port=8080 \ No newline at end of file From bc3eebe3cbd31834c1587ed4faaa32599b44712c Mon Sep 17 00:00:00 2001 From: grego952 Date: Wed, 13 May 2026 14:36:28 +0200 Subject: [PATCH 3/6] Fix links --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f98971e9..e43b9732 100644 --- a/README.md +++ b/README.md @@ -91,8 +91,8 @@ Running various samples requires access to the Kyma environment. There are also | Name | Description | References | | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | -| [Deploy a Go PostgreSQL API Endpoint in SAP BTP, Kyma Runtime](./api-postgresql-go/README.md) | This sample provides a Golang API endpoint for communication with an MS SQL database | [Tutorial](https://developers.sap.com/tutorials/cp-kyma-api-mssql-golang.html) | -| [Use and Seed SAP BTP PostgreSQL in SAP BTP, Kyma Runtime](./database-postgres/README.md) | This sample demonstrates how to containerize and deploy an MS SQL database | [Tutorial](https://developers.sap.com/tutorials/cp-kyma-mssql-deployment.html) | +| [Deploy a Go PostgreSQL API Endpoint in SAP BTP, Kyma Runtime](./api-postgresql-go/README.md) | This sample provides a Golang API endpoint for communication with an PostgreSQL database | [Tutorial](https://developers.sap.com/tutorials/cp-kyma-api-postgres-golang.html) | +| [Use and Seed SAP BTP PostgreSQL in SAP BTP, Kyma Runtime](./database-postgres/README.md) | This sample demonstrates how to seed the PostgreSQL database with sample schema and data using a Kubernetes Job | [Tutorial](https://developers.sap.com/tutorials/cp-kyma-postgres-seed.html) | ## Advanced Scenarios From 8092bdedf7ae7d42c2c6c72f9e4c0f6b93ce3fb9 Mon Sep 17 00:00:00 2001 From: grego952 Date: Thu, 14 May 2026 10:10:04 +0200 Subject: [PATCH 4/6] Update MovieController --- .../src/main/java/com/example/movies/MovieController.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/movies-rest/src/main/java/com/example/movies/MovieController.java b/movies-rest/src/main/java/com/example/movies/MovieController.java index 3c0fb50d..44614bad 100644 --- a/movies-rest/src/main/java/com/example/movies/MovieController.java +++ b/movies-rest/src/main/java/com/example/movies/MovieController.java @@ -12,6 +12,7 @@ import java.io.IOException; import java.util.List; +import java.util.UUID; @RestController @RequestMapping("/movies") @@ -50,7 +51,7 @@ public Movie get(@PathVariable String id) { @ResponseStatus(HttpStatus.CREATED) @Operation(summary = "Create a new movie") public Movie create(@org.springframework.web.bind.annotation.RequestBody Movie movie) throws Exception { - Movie saved = movie.withId(String.valueOf(System.currentTimeMillis())); + Movie saved = movie.withId(UUID.randomUUID().toString()); putMovie(saved); return saved; } From f8b62c93910388d8b5e515b0b91f4cd7bbff671f Mon Sep 17 00:00:00 2001 From: grego952 Date: Thu, 14 May 2026 10:23:24 +0200 Subject: [PATCH 5/6] Update docs --- movies-rest/README.md | 82 ++++++++----------------------------------- 1 file changed, 14 insertions(+), 68 deletions(-) diff --git a/movies-rest/README.md b/movies-rest/README.md index a73010c9..4fe46da0 100644 --- a/movies-rest/README.md +++ b/movies-rest/README.md @@ -1,6 +1,17 @@ -# Movies REST API — Java / SAP BTP Kyma +# Deploy a Spring Boot Movies REST API in SAP BTP, Kyma Runtime -A Spring Boot REST API that keeps movie records as JSON objects in an S3-compatible **SAP Object Store** service. The app is deployed to **SAP BTP, Kyma runtime** using the `kyma-project/setup-kyma-cli/app-push` GitHub Action. +## Overview + +> [!NOTE] +> This sample is used in the Fast Prototyping in SAP BTP, Kyma Runtime Using App Push tutorial. + +This sample provides a Spring Boot REST API that manages movie records stored as JSON objects in an S3-compatible **SAP Object Store** service. + +This sample demonstrates how to: + +- How to go from source code to a running, externally accessible application on Kyma runtime in a single command +- How to iterate quickly on a prototype without writing Kubernetes manifests, Dockerfiles, or configuring a container registry +- How to evolve a local prototype into an automated GitHub Actions CD pipeline ## Architecture @@ -41,7 +52,7 @@ Each movie is stored as a JSON file at `movies/.json` inside the bound S3 bu Interactive documentation is available at `/swagger-ui.html` after deployment. -### Movie resource +### Movie Resource ```json { @@ -52,68 +63,3 @@ Interactive documentation is available at `/swagger-ui.html` after deployment. "rating": 8.1 } ``` - -## Prerequisites - -- SAP BTP Kyma cluster -- GitHub repository secrets: - - `SERVER` — Kyma API server URL - - `CA_CRT` — Kyma cluster CA certificate -- OIDC client configured with audience `my-client-id-for-gh-action` - -## SAP Object Store Setup - -Apply the Kubernetes manifests to provision the Object Store service and bind it to the app namespace: - -```bash -kubectl apply -f movies-rest/service-instance.yaml -kubectl apply -f movies-rest/service-binding.yaml -``` - -These create: -- `ServiceInstance` `object-store-instance` — provisions an S3-compatible bucket via the SAP Service Operator -- `ServiceBinding` `object-store-binding` — injects credentials as a Kubernetes secret that the app reads at startup - -## Deployment - -Push to the `main` branch. The GitHub Actions workflow will: - -1. Install Kyma CLI -2. Obtain a kubeconfig using OIDC -3. Build and push the container image -4. Deploy the app to the `movie-rest` namespace with: - - Istio sidecar injection enabled - - Public ingress (`expose: true`) - - The `object-store-binding` secret mounted as a service binding - - JVM tuning from `.env` - -The workflow appends `/swagger-ui.html` to the output URL so you go directly on the API docs. - -## Local Development - -The app requires an Object Store service binding to start. For local testing, provide the binding via environment variables or a local `VCAP_SERVICES` / secrets file supported by the SAP Service Binding library. - -```bash -cd movies-rest -mvn spring-boot:run -``` - -The server starts on port `8080`. - -## Project Structure - -``` -movies-rest/ -├── src/main/java/com/example/movies/ -│ ├── Application.java # Spring Boot entry point -│ ├── Movie.java # Record: id, title, year, director, rating -│ ├── MovieController.java # REST controller — CRUD over S3 -│ └── ObjectStoreConfig.java # Reads SAP service binding, creates S3Client -├── src/main/resources/ -│ └── application.properties # server.port=8080 -├── .github/workflows/deploy.yaml # CI/CD pipeline -├── service-instance.yaml # SAP BTP ServiceInstance manifest -├── service-binding.yaml # SAP BTP ServiceBinding manifest -├── .env # JVM tuning flags for Buildpacks -└── pom.xml -``` From a3df0bb8363fbebcb7896076456a4bd2b241d2e7 Mon Sep 17 00:00:00 2001 From: grego952 Date: Thu, 14 May 2026 10:26:47 +0200 Subject: [PATCH 6/6] Update id --- movies-rest/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/movies-rest/README.md b/movies-rest/README.md index 4fe46da0..dadb9a07 100644 --- a/movies-rest/README.md +++ b/movies-rest/README.md @@ -56,7 +56,7 @@ Interactive documentation is available at `/swagger-ui.html` after deployment. ```json { - "id": "1714900000000", + "id": "4e92e9c6-ebe3-4840-ae3c-2ede35ee4b74", "title": "Blade Runner", "year": 1982, "director": "Ridley Scott",